diff --git a/Cargo.lock b/Cargo.lock index 0d7c857f..269350a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1162,7 +1162,7 @@ dependencies = [ "connetto-core", "pkcs8", "rand_core 0.6.4", - "rcgen 0.14.10", + "rcgen", "rpassword", "tempfile", "thiserror 2.0.20", @@ -1205,7 +1205,7 @@ dependencies = [ "openidconnect", "parking_lot", "pg2sqlite", - "rcgen 0.13.2", + "rcgen", "reqwest 0.12.28", "ring", "rmp-serde", @@ -1243,7 +1243,7 @@ version = "0.0.0" dependencies = [ "diesel", "futures-util", - "rcgen 0.14.10", + "rcgen", "rmp-serde", "rustls", "rustls-pki-types", @@ -1376,7 +1376,7 @@ dependencies = [ "connetto-core", "mdns-sd", "parking_lot", - "rcgen 0.14.10", + "rcgen", "ring", "rmp-serde", "rustls", @@ -1440,7 +1440,7 @@ dependencies = [ "pem", "pg2sqlite", "pg_walstream", - "rcgen 0.14.10", + "rcgen", "reqwest 0.12.28", "ring", "rls2fga", @@ -7289,19 +7289,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rcgen" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "yasna 0.5.2", -] - [[package]] name = "rcgen" version = "0.14.10" @@ -7312,7 +7299,7 @@ dependencies = [ "rustls-pki-types", "time", "x509-parser", - "yasna 0.6.0", + "yasna", ] [[package]] @@ -10623,15 +10610,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yasna" version = "0.6.0" diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index b4408f5e..496de21f 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -165,8 +165,10 @@ ring = { version = "0.17", features = ["std"], optional = true } # bundle, the same step the server runs. Native only, with the binary. connetto-schema = { workspace = true, optional = true } # The peer link the device serves and dials (R76). Its discovery feature is -# on, so a peer build always browses and autolinks what it finds (R76 slice 3). -connetto-peer = { path = "../connetto-peer", optional = true, features = ["discovery"] } +# on, so a peer build always browses and autolinks what it finds (R76 slice +# 3), and its bluetooth feature carries the beacon and the exchange (R76 +# slice 5). +connetto-peer = { path = "../connetto-peer", optional = true, features = ["discovery", "bluetooth"] } # The page codec, one per target, both selected here rather than by a feature # because `ReplicaCipher::Encrypted` cannot be honest without it: in a SQLite # with no codec, `PRAGMA key` is an unrecognised pragma that succeeds silently @@ -328,8 +330,9 @@ openidconnect = { version = "4.0.1", default-features = false, features = [ # PKCE token of their own. Test-only since R47: the client's own PKCE token now # comes from the platform RNG through `getrandom`. uuid = { version = "1", features = ["v4", "v5"] } -# A self-signed cert and the server stack for a wss target the builder tests dial. -rcgen = "0.13" +# The Bluetooth tests mint a root, an issuer and the device keys it signs, +# the same keys the peer crate's proofs mint. +rcgen = { version = "0.14.10", default-features = false, features = ["ring", "x509-parser"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } tokio = { version = "1", features = ["test-util"] } diff --git a/crates/connetto-client/src/bluetooth.rs b/crates/connetto-client/src/bluetooth.rs new file mode 100644 index 00000000..4dc8d418 --- /dev/null +++ b/crates/connetto-client/src/bluetooth.rs @@ -0,0 +1,2972 @@ +//! The Bluetooth beacon and the exchange over it (R76). +//! +//! The machine is a small loop beside the hotspot machine's. An API method +//! sends a command, the machine looks at its backends on a tick, and applies +//! the beacon and joiner tables to the answers, the events and the seams the +//! client gave it. The proofs drive the tables through fake backends, and the +//! Android backend reaches the bundled plugin through the application's JNI +//! access. + +use std::collections::HashMap; +use std::future::Future; +use std::io; +use std::net::IpAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use connetto_core::device_cert::DeviceIdentity; +use connetto_peer::{ + Beacon, ChunkStream, EXCHANGE_BOUND, ExchangeError, Fingerprint, Node, OfferFrame, +}; +use tokio::sync::{mpsc, oneshot, watch}; + +use crate::ClientEvent; +use crate::hotspot::{ + HotspotError, HotspotOffer, HotspotSecurity, JOIN_BOUND, JoinError, MARGIN, Reply, TICK, +}; + +#[cfg(target_os = "android")] +mod android; + +#[cfg(target_os = "android")] +pub(crate) use android::AndroidBluetoothBackend; + +/// The pace of the machine's look while an exchange runs. +const FAST_TICK: Duration = Duration::from_millis(20); +/// The time a prompt action gets (R76 decision 21). +pub(crate) const PROMPT_BOUND: Duration = Duration::from_secs(60); +/// The time a host's beacon goes unseen before it is reported gone. +const GONE_AFTER: Duration = Duration::from_secs(30); +/// The exchanges a host runs at once, the fifth connection refused (R76). +const MAX_EXCHANGES: usize = 4; +/// The GATT header the negotiated packet size leaves to the chunks. +const CHUNK_OVERHEAD: usize = 3; +/// The longest attribute value GATT allows, which a notification or a write +/// cannot pass however large the packet. +const MAX_ATTRIBUTE_VALUE: usize = 512; + +/// The device of a nearby host, the key the platform's Bluetooth stands on. +pub type HostId = u64; + +/// The platform's Bluetooth, as the client reads it (R76 decision 21). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BluetoothState { + /// The radio is on and the permissions are granted. + Ready, + /// The radio is off. + Off, + /// A permission is not granted. + NotPermitted, + /// This system has no Bluetooth. + Unsupported, +} + +impl std::fmt::Display for BluetoothState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + Self::Ready => "ready", + Self::Off => "off", + Self::NotPermitted => "not permitted", + Self::Unsupported => "unsupported", + }; + f.write_str(text) + } +} + +/// The platform's prompt action, once it finishes (R76 decision 21). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PromptOutcome { + /// No action ran, the standing was already the call's. + NotAsked, + /// The user declined it. + Declined, + /// The system showed the settings pane instead. + SentToSettings, + /// The action could not be shown. + Blocked, +} + +/// The reasons a Bluetooth call will not go (R76). +#[derive(Debug, thiserror::Error)] +pub enum BluetoothError { + /// Bluetooth is off, with the prompt's outcome. + #[error("Bluetooth is off, the prompt {0}")] + Off(PromptOutcome), + /// The Bluetooth permissions are not granted, with the prompt's outcome. + #[error("the Bluetooth permissions are not granted {missing:?}, the prompt {outcome}")] + NotPermitted { + /// The missing permissions' names. + missing: Vec, + /// The prompt's outcome. + outcome: PromptOutcome, + }, + /// This system has no Bluetooth. + #[error("this system has no Bluetooth")] + Unsupported, + /// An exchange is already running. + #[error("an exchange is already running")] + Busy, + /// The exchange failed, with the link's reason. + #[error(transparent)] + Exchange(#[from] ExchangeError), + /// The exchange did not finish within its bound. + #[error("the exchange did not finish within its bound")] + TimedOut, + /// The platform's Bluetooth failed. + #[error("the platform's Bluetooth failed: {0}")] + Failed(String), +} + +impl std::fmt::Display for PromptOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + Self::NotAsked => "not asked", + Self::Declined => "declined", + Self::SentToSettings => "sent to settings", + Self::Blocked => "blocked", + }; + f.write_str(text) + } +} + +/// The reasons a nearby join will not go (R76). +#[derive(Debug, thiserror::Error)] +pub enum JoinNearbyError { + /// Bluetooth will not go, with the reason. + #[error(transparent)] + Bluetooth(#[from] BluetoothError), + /// The exchange's offer could not be joined, with the hotspot's reason. + #[error(transparent)] + Join(#[from] JoinError), +} + +/// The answer a `host_hotspot` call gets (R76). +#[derive(Debug)] +pub struct Hosted { + /// The hotspot's details. + pub offer: HotspotOffer, + /// The beacon's outcome, the refusal beside a still-unavailable radio. + pub beacon: Result<(), BluetoothError>, +} + +/// The host's beacon standing, for the application's panel (R76). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BeaconState { + /// Advertising, under the beacon's prefix. + Advertising { + /// The first 8 bytes of the presented leaf's fingerprint. + prefix: [u8; 8], + }, + /// Off, with the reason. + Off { + /// Why the beacon is not advertising. + reason: &'static str, + }, +} + +/// An event the host's GATT service reports (R76). +#[cfg_attr( + not(target_os = "android"), + expect( + dead_code, + reason = "the platform backends construct these events, a non-Android build reads them through the tables only" + ) +)] +#[derive(Clone, Debug)] +pub enum PeripheralEvent { + /// A joiner connected, with the negotiated packet size. + Connected { + /// The device's key. + device: u64, + /// The negotiated packet size. + mtu: u16, + }, + /// A chunk the joiner wrote. + Chunk { + /// The device's key. + device: u64, + /// The chunk's bytes. + bytes: Vec, + }, + /// A joiner disconnected. + Disconnected { + /// The device's key. + device: u64, + }, + /// The platform's Bluetooth failed. + Failed(String), +} + +/// An event the joiner's central reports (R76). +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the central's backend arrives with btleplug (R76 decision 20), so only the proofs construct these events" + ) +)] +#[derive(Clone, Debug)] +pub enum CentralEvent { + /// A beacon was seen, with its service data and signal. + Seen { + /// The host's key. + host: HostId, + /// The beacon's service data. + service_data: Vec, + /// The signal's strength. + rssi: i16, + }, + /// The host connected, with the negotiated packet size. + Connected { + /// The host's key. + host: HostId, + /// The negotiated packet size. + mtu: u16, + }, + /// A chunk the host notified. + Chunk { + /// The host's key. + host: HostId, + /// The chunk's bytes. + bytes: Vec, + }, + /// The host disconnected. + Disconnected { + /// The host's key. + host: HostId, + }, +} + +/// The platform's Bluetooth readiness, polled by the machine (R76 decision 21). +pub trait ReadinessBackend: Send + Sync { + /// The platform's standing. + fn state(&self) -> BluetoothState; + /// The permissions not granted, by name. + fn missing_permissions(&self) -> Vec; + /// Start the platform's action the waiting call wants. + fn prompt(&self) -> Result<(), BluetoothError>; + /// The finished action's outcome, `None` while it runs. + fn prompt_outcome(&self) -> Option; +} + +/// The host's GATT service and advertisement, polled by the machine (R76). +pub trait PeripheralBackend: Send + Sync { + /// Start the advertisement and the service under `beacon`. + fn start(&self, beacon: [u8; 9]) -> Result<(), BluetoothError>; + /// Stop the advertisement and close the service. + fn stop(&self); + /// The service's events, drained. + fn poll(&self) -> Vec; + /// Write a chunk to the device's characteristic. + fn notify(&self, device: u64, bytes: &[u8]) -> Result<(), BluetoothError>; + /// End the device's connection. + fn disconnect(&self, device: u64); +} + +/// The joiner's central, polled by the machine (R76). +pub trait CentralBackend: Send + Sync { + /// Start scanning for the beacon. + fn start_scan(&self); + /// Stop scanning. + fn stop_scan(&self); + /// The central's events, drained. + fn poll(&self) -> Vec; + /// Connect to the host. + fn connect(&self, host: HostId); + /// Write a chunk to the host's characteristic. + fn write(&self, host: HostId, bytes: &[u8]) -> Result<(), BluetoothError>; + /// End the host's connection. + fn disconnect(&self, host: HostId); +} + +/// The backends on a system with no Bluetooth. +pub struct UnsupportedBackend; + +impl ReadinessBackend for UnsupportedBackend { + fn state(&self) -> BluetoothState { + BluetoothState::Unsupported + } + + fn missing_permissions(&self) -> Vec { + Vec::new() + } + + fn prompt(&self) -> Result<(), BluetoothError> { + Err(BluetoothError::Unsupported) + } + + fn prompt_outcome(&self) -> Option { + Some(PromptOutcome::NotAsked) + } +} + +impl PeripheralBackend for UnsupportedBackend { + fn start(&self, _beacon: [u8; 9]) -> Result<(), BluetoothError> { + Err(BluetoothError::Unsupported) + } + + fn stop(&self) {} + + fn poll(&self) -> Vec { + Vec::new() + } + + fn notify(&self, _device: u64, _bytes: &[u8]) -> Result<(), BluetoothError> { + Err(BluetoothError::Unsupported) + } + + fn disconnect(&self, _device: u64) {} +} + +/// A system's readiness, its peripheral, and its central where it runs one. +pub(crate) type Backends = ( + Arc, + Arc, + Option>, +); + +/// The backends on a system with no Bluetooth, which runs no central. +pub(crate) fn unsupported_backends() -> Backends { + ( + Arc::new(UnsupportedBackend) as Arc, + Arc::new(UnsupportedBackend) as Arc, + None, + ) +} + +/// The bytes one chunk carries over a connection of `mtu`, within both the +/// packet and the longest attribute value GATT allows. +fn chunk_size(mtu: u16) -> usize { + usize::from(mtu) + .saturating_sub(CHUNK_OVERHEAD) + .clamp(1, MAX_ATTRIBUTE_VALUE) +} + +/// The offer the machine hands the exchange, from the hotspot's. +fn offer_frame(offer: &HotspotOffer) -> OfferFrame { + let security = match offer.security { + HotspotSecurity::Wpa2 => 0, + HotspotSecurity::Wpa3 => 1, + }; + OfferFrame::new( + offer.ssid.clone(), + offer.passphrase.clone(), + security, + offer.port, + ) +} + +/// The hotspot's offer, from the exchange's. +fn hotspot_offer(frame: &OfferFrame) -> HotspotOffer { + let security = match frame.security { + 0 => HotspotSecurity::Wpa2, + // A code the platform does not name keeps the stronger security. + _ => HotspotSecurity::Wpa3, + }; + HotspotOffer::new( + frame.ssid.clone(), + frame.passphrase().to_owned(), + security, + frame.port, + ) +} + +/// A stream error the machine names when its own channel fails. +fn channel_error(text: &'static str) -> ExchangeError { + ExchangeError::Io(io::Error::new(io::ErrorKind::BrokenPipe, text)) +} + +/// The host side's exchanges (R76). +struct HostExchange { + /// The stream's reader, the joiner's chunks in order. + inbound: mpsc::Sender>, + /// The stream's writer, the host's chunks out of order. + outbound: mpsc::Receiver>, + /// The exchange's answer, once it ends. + reply: oneshot::Receiver>, + /// The bound the exchange gets. + deadline: tokio::time::Instant, +} + +/// The host's beacon standing (R76). +enum HostBtState { + /// Not advertising. + Off, + /// Advertising under `prefix`, with the running exchanges. + Advertising { + /// The beacon's prefix the advertisement carries. + prefix: [u8; 8], + /// The exchanges running beneath the cap. + exchanges: HashMap, + }, +} + +/// The manual exchange's answer, by its kind. +enum ManualReply { + /// The offer the fetch answers. + Fetch(Reply>), + /// The gateway the join answers. + Join(Reply>), +} + +/// The exchange's kind, what its offer goes to (R76). +enum Kind { + /// The fetch's, answered to the caller. + Fetch, + /// The join's, handed to `join_hotspot`. + Join, +} + +/// The joiner's standing (R76). +enum JoinerState { + /// Not scanning, nothing running. + Idle, + /// Scanning for the beacon. + Scanning, + /// The exchange with `host` running, with its answer and its bound. + Exchanging { + /// The host the exchange runs with. + host: HostId, + /// What the exchange's offer goes to. + kind: Kind, + /// The manual exchange's answer, `None` for autojoin. + reply: Option, + /// The bound the exchange gets. + deadline: tokio::time::Instant, + /// The stream's reader, the host's chunks in order, once connected. + inbound: Option>>, + /// The stream's writer, the joiner's chunks out of order, once + /// connected. + outbound: Option>>, + /// The exchange's answer, once it ends. + task_reply: Option>>, + }, +} + +/// A seen host, for the panel and the tried rule (R76). +struct HostRecord { + /// The beacon's prefix last seen. + prefix: [u8; 8], + /// When the beacon was last seen, on the monotonic clock. + last_seen: tokio::time::Instant, + /// The signal last seen. + rssi: Option, + /// Whether the join's been tried, until the beacon changes. + tried: bool, +} + +/// A prompt action's waiting call (R76 decision 21). +enum PromptPending { + /// The enable's answer. + Enable { + /// The answer, once the action finishes. + reply: Reply>, + }, + /// The fetch's answer. + Fetch { + /// The host the fetch runs with. + host: HostId, + /// The answer, once the action finishes. + reply: Reply>, + }, + /// The join's answer. + Join { + /// The host the join runs with. + host: HostId, + /// The answer, once the action finishes. + reply: Reply>, + }, +} + +/// The platform's one prompt action in flight, with its waiters (R76 +/// decision 21). +struct PromptBatch { + /// The calls waiting on the action. + pending: Vec, + /// The bound the action gets. + deadline: tokio::time::Instant, +} + +/// The hosted answers in flight, beside the beacon's outcome (R76). +struct HostedPending { + /// The hotspot offers' answers, from the hotspot machine. + offers: Vec>>, + /// The callers' answers. + replies: Vec>>, + /// The beacon's outcome, once the standing decides it. + beacon: Option, +} + +/// The beacon's outcome, standing beside the hosted's answers (R76). +#[derive(Clone, Copy, Debug)] +enum BeaconResolution { + /// The beacon advertises, so the call's answer stands on the offer. + Ok, + /// The standing's refusal, re-derived for each caller's answer. + Refusal { + /// The standing the refusal stands on. + state: BluetoothState, + /// The prompt's outcome, `None` where the bound ran. + outcome: Option, + }, +} + +/// A joined answer in flight, from the join machine (R76). +struct JoinPending { + /// The host the join runs with. + host: HostId, + /// The join machine's answer, once the network is up. + reply: oneshot::Receiver>, + /// The caller's answer, for the manual join. + caller: Option>>, + /// The bound the join gets. + deadline: tokio::time::Instant, +} + +/// The beacon's outcome decision, computed outside the hosted's borrow. +enum BeaconDecision { + /// Resolve the beacon with the outcome. + Resolve(BeaconResolution), + /// Start the platform's action, which decides the beacon. + StartAction, + /// Wait, the standing does not decide yet. + Wait, +} + +/// The seam handing an exchange's offer to the hotspot's join. +pub(crate) type JoinSeam = + Arc>) + Send + Sync>; + +/// The machine, one per client (R76). +pub(crate) struct Machine { + readiness: Arc, + peripheral: Arc, + central: Option>, + node: Node, + /// The fingerprint the device serves, `None` where it does not. + serving: Arc Option + Send + Sync>, + /// Hand the exchange's offer to the hotspot's join. + join: JoinSeam, + /// Emit an event to the application. + emit: Arc, + /// Whether the scan runs, on by default (R76 decision 19). + scan: bool, + /// Whether autojoin joins the strongest host, off by default (R76 + /// decision 19). + autojoin: bool, + /// Whether the call-driven prompts run, on by default (R76 decision 21). + prompt: bool, + /// The readiness last reported, for the change event. + readiness_state: BluetoothState, + host: HostBtState, + joiner: JoinerState, + hosts: HashMap, + hosted: Option, + pending_join: Vec, + prompting: Option, + /// The calls waiting for the platform's action to start. + action_waiters: Vec, + beacon: watch::Sender, +} + +impl Machine { + /// The machine over the backends, the peer node, the scan and autojoin + /// standings, the prompt standing, and the serving, join and event + /// seams. The receiver stands the host's beacon for the panel. + #[expect( + clippy::too_many_arguments, + reason = "the three backends and the three seams join the node and its three standings, and a config struct would hide the same arity behind another type" + )] + pub(crate) fn new( + readiness: Arc, + peripheral: Arc, + central: Option>, + node: Node, + scan: bool, + autojoin: bool, + prompt: bool, + serving: Arc Option + Send + Sync>, + join: JoinSeam, + emit: Arc, + ) -> (Self, watch::Receiver) { + let serving_present = (serving)().is_some(); + let (beacon, beacon_rx) = watch::channel(BeaconState::Off { + reason: Self::beacon_reason(readiness.state(), false, serving_present), + }); + ( + Self { + readiness, + peripheral, + central, + node, + serving, + join, + emit, + scan, + autojoin, + prompt, + readiness_state: BluetoothState::Unsupported, + host: HostBtState::Off, + joiner: JoinerState::Idle, + hosts: HashMap::new(), + hosted: None, + pending_join: Vec::new(), + prompting: None, + action_waiters: Vec::new(), + beacon, + }, + beacon_rx, + ) + } + + /// The reason the beacon is off, for the panel (R76). + fn beacon_reason(state: BluetoothState, hosting: bool, serving: bool) -> &'static str { + match state { + BluetoothState::Unsupported => "no bluetooth on this system", + BluetoothState::NotPermitted => "the bluetooth permissions are not granted", + BluetoothState::Off => "bluetooth is off", + BluetoothState::Ready if !hosting => "the device does not host a hotspot", + BluetoothState::Ready if !serving => "the device does not serve its peer", + BluetoothState::Ready => "the beacon is not started", + } + } + + /// The host's hosted answer, linked by the runner from the hotspot + /// machine's (R76). + pub(crate) fn hosted( + &mut self, + offer: oneshot::Receiver>, + reply: Reply>, + ) { + let Some(hosted) = self.hosted.as_mut() else { + self.hosted = Some(HostedPending { + offers: vec![offer], + replies: vec![reply], + beacon: None, + }); + return; + }; + hosted.offers.push(offer); + hosted.replies.push(reply); + } + + /// Take `command` and steer the tables to their rows. + pub(crate) fn handle(&mut self, command: Command) { + match command { + Command::Enable(reply) => match self.readiness.state() { + BluetoothState::Ready => { + let _ = reply.send(Ok(())); + } + BluetoothState::Unsupported => { + let _ = reply.send(Err(BluetoothError::Unsupported)); + } + // The enable always runs the platform's action, the prompt's + // standing beside it (R76 decision 21). + _ => self.prompt_for(PromptPending::Enable { reply }, true), + }, + // The runner links the host's answer to the hotspot machine's, + // so the machine does not take it on its own. + Command::Host(_) => {} + Command::JoinNearby { host, reply } => { + self.begin_manual(host, Kind::Join, ManualReply::Join(reply)); + } + Command::Fetch { host, reply } => { + self.begin_manual(host, Kind::Fetch, ManualReply::Fetch(reply)); + } + Command::SetScan(on) => self.scan = on, + Command::SetAutojoin(on) => { + self.autojoin = on; + if on { + self.scan = true; + } + } + Command::SetPrompt(on) => self.prompt = on, + } + } + + /// A manual exchange from the call's rows (R76 joiner table). + fn begin_manual(&mut self, host: HostId, kind: Kind, reply: ManualReply) { + if self.central.is_none() { + // The system runs no central, so the call has nothing to join + // with. + Self::answer_manual(reply, BluetoothError::Unsupported); + return; + } + if matches!(self.joiner, JoinerState::Exchanging { .. }) { + // An exchange is running, so the call answers Busy. + Self::answer_manual(reply, BluetoothError::Busy); + return; + } + match self.readiness.state() { + BluetoothState::Ready => self.begin_join_exchange(host, kind, Some(reply)), + BluetoothState::Unsupported => { + Self::answer_manual(reply, BluetoothError::Unsupported); + } + // Decision 21's action runs once, unless the prompt is switched + // off, then the rows go on (R76 joiner table). + _ => match reply { + ManualReply::Fetch(reply) => { + self.prompt_for(PromptPending::Fetch { host, reply }, false); + } + ManualReply::Join(reply) => { + self.prompt_for(PromptPending::Join { host, reply }, false); + } + }, + } + } + + /// The manual exchange's answer, on its error rows. + fn answer_manual(reply: ManualReply, err: BluetoothError) { + match reply { + ManualReply::Fetch(reply) => { + let _ = reply.send(Err(err)); + } + ManualReply::Join(reply) => { + let _ = reply.send(Err(JoinNearbyError::Bluetooth(err))); + } + } + } + + /// The prompt action's waiting call, started once (R76 decision 21). + fn prompt_for(&mut self, pending: PromptPending, from_enable: bool) { + if !from_enable && !self.prompt { + let err = self.state_error(self.readiness.state()); + Self::answer_pending(pending, err); + return; + } + self.action_waiters.push(pending); + if self.prompting.is_none() { + self.start_prompt_action(); + } + } + + /// Start the platform's one action for the waiting calls, answering + /// whether it started. + fn start_prompt_action(&mut self) -> bool { + let waiters = core::mem::take(&mut self.action_waiters); + if let Err(failure) = self.readiness.prompt() { + // The action will not start, so the waiters answer with the + // standing's reason. + for pending in waiters { + let err = match self.readiness.state() { + state @ (BluetoothState::Off | BluetoothState::NotPermitted) => { + self.state_error_with(state, PromptOutcome::Blocked) + } + state => self.state_error(state), + }; + Self::answer_pending(pending, err); + } + tracing::warn!(%failure, "the Bluetooth action did not start"); + return false; + } + self.prompting = Some(PromptBatch { + pending: waiters, + deadline: tokio::time::Instant::now() + PROMPT_BOUND, + }); + true + } + + /// The standing's error, with no action asked. + fn state_error(&self, state: BluetoothState) -> BluetoothError { + match state { + BluetoothState::Ready => { + BluetoothError::Failed("the Bluetooth standing stands Ready".into()) + } + BluetoothState::Off => BluetoothError::Off(PromptOutcome::NotAsked), + BluetoothState::NotPermitted => BluetoothError::NotPermitted { + missing: self.readiness.missing_permissions(), + outcome: PromptOutcome::NotAsked, + }, + BluetoothState::Unsupported => BluetoothError::Unsupported, + } + } + + /// The standing's error, with the action's outcome. + fn state_error_with(&self, state: BluetoothState, outcome: PromptOutcome) -> BluetoothError { + match state { + BluetoothState::Off => BluetoothError::Off(outcome), + BluetoothState::NotPermitted => BluetoothError::NotPermitted { + missing: self.readiness.missing_permissions(), + outcome, + }, + state => self.state_error(state), + } + } + + /// A waiting call's answer, on its error rows. + fn answer_pending(pending: PromptPending, err: BluetoothError) { + match pending { + PromptPending::Enable { reply } => { + let _ = reply.send(Err(err)); + } + PromptPending::Fetch { reply, .. } => { + let _ = reply.send(Err(err)); + } + PromptPending::Join { reply, .. } => { + let _ = reply.send(Err(JoinNearbyError::Bluetooth(err))); + } + } + } + + /// The action's outcome, once the platform reports it or its bound runs + /// (R76 decision 21). + fn apply_prompt( + &mut self, + state: BluetoothState, + outcome: Option, + batch: PromptBatch, + ) { + for pending in batch.pending { + match (state, outcome) { + // The bound with no outcome answers its own. + (_, None) => Self::answer_pending(pending, BluetoothError::TimedOut), + (BluetoothState::Ready, Some(_)) => match pending { + PromptPending::Enable { reply } => { + let _ = reply.send(Ok(())); + } + PromptPending::Fetch { host, reply } => { + self.begin_join_exchange( + host, + Kind::Fetch, + Some(ManualReply::Fetch(reply)), + ); + } + PromptPending::Join { host, reply } => { + self.begin_join_exchange(host, Kind::Join, Some(ManualReply::Join(reply))); + } + }, + (BluetoothState::Unsupported, Some(_)) => { + Self::answer_pending(pending, BluetoothError::Unsupported); + } + (state @ (BluetoothState::Off | BluetoothState::NotPermitted), Some(outcome)) => { + Self::answer_pending(pending, self.state_error_with(state, outcome)); + } + } + } + // The beacon's outcome, for the host's answer, decided outside the + // hosted's borrow. + let resolution = match (state, outcome) { + (BluetoothState::Ready, Some(_)) => BeaconResolution::Ok, + _ => BeaconResolution::Refusal { state, outcome }, + }; + if let Some(hosted) = self.hosted.as_mut() + && hosted.beacon.is_none() + { + hosted.beacon = Some(resolution); + } + } + + /// Look at the backends' outcomes and apply the tables' transitions. + pub(crate) fn tick(&mut self, hosting: Option<&HotspotOffer>, joined: bool) { + let now = tokio::time::Instant::now(); + let state = self.readiness.state(); + let serving = (self.serving)(); + + // The readiness's change, reported once. + if state != self.readiness_state { + self.readiness_state = state; + (self.emit)(ClientEvent::BluetoothChanged { state }); + } + + // The prompt action's outcome, once the platform reports it or its + // bound runs. + if let Some(deadline) = self.prompting.as_ref().map(|batch| batch.deadline) { + let outcome = self.readiness.prompt_outcome(); + if (outcome.is_some() || now >= deadline) + && let Some(batch) = self.prompting.take() + { + self.apply_prompt(state, outcome, batch); + } + } + + self.tick_host(state, hosting, serving.as_ref()); + self.tick_joiner(state, joined, serving.is_some(), now); + self.answer_hosted(state); + self.answer_joined(now); + self.update_beacon(state, hosting.is_some(), serving.is_some()); + } + + /// The host's beacon rows (R76 host table). + fn tick_host( + &mut self, + state: BluetoothState, + hosting: Option<&HotspotOffer>, + serving: Option<&Fingerprint>, + ) { + match &mut self.host { + // The device hosts a hotspot and serves, so the beacon starts. + HostBtState::Off if state == BluetoothState::Ready => { + if let (Some(_), Some(fingerprint)) = (hosting, serving) { + let beacon = Beacon::of(fingerprint); + if self.peripheral.start(beacon.to_service_data()).is_ok() { + self.host = HostBtState::Advertising { + prefix: beacon.prefix, + exchanges: HashMap::new(), + }; + } else { + // The platform will not advertise, so the standing + // retries on its next look. + tracing::warn!( + "the beacon could not start, so it retries on the next look" + ); + } + } + } + HostBtState::Advertising { prefix, exchanges } => { + // The hotspot stops, the device stops serving, or Bluetooth + // leaves Ready, so the beacon stops with its exchanges. + if state != BluetoothState::Ready || hosting.is_none() || serving.is_none() { + self.peripheral.stop(); + self.host = HostBtState::Off; + return; + } + if let Some(fingerprint) = serving { + // A renewal changes the leaf, so the beacon advertises + // again under the new prefix. + if fingerprint.prefix() != *prefix { + let beacon = Beacon::of(fingerprint); + if self.peripheral.start(beacon.to_service_data()).is_ok() { + *prefix = beacon.prefix; + } + } + } + // The exchanges' rows, the outbound, the outcome, the bound. + let ended = tick_host_exchanges(&self.peripheral, exchanges); + for (device, _outcome) in ended { + self.peripheral.disconnect(device); + } + } + HostBtState::Off => {} + } + // The service's events. + for event in self.peripheral.poll() { + match event { + PeripheralEvent::Connected { device, mtu } => { + let HostBtState::Advertising { exchanges, .. } = &mut self.host else { + self.peripheral.disconnect(device); + continue; + }; + if exchanges.len() >= MAX_EXCHANGES || exchanges.contains_key(&device) { + // A joiner connects while four exchanges run, so it + // is disconnected at once. + self.peripheral.disconnect(device); + continue; + } + if let Some(offer) = hosting.cloned() { + // A joiner connects while fewer than four exchanges + // run, so it runs its exchange. + start_host_exchange(&self.node, exchanges, device, mtu, offer); + } else { + self.peripheral.disconnect(device); + } + } + PeripheralEvent::Chunk { device, bytes } => { + if let HostBtState::Advertising { exchanges, .. } = &mut self.host { + let reader_gone = match exchanges.get_mut(&device) { + Some(exchange) => exchange.inbound.try_send(bytes).is_err(), + None => false, + }; + // The stream's reader is gone, so the exchange ends. + if reader_gone && end_host_exchange(exchanges, device) { + self.peripheral.disconnect(device); + } + } + } + PeripheralEvent::Disconnected { device } => { + if let HostBtState::Advertising { exchanges, .. } = &mut self.host + && end_host_exchange(exchanges, device) + { + self.peripheral.disconnect(device); + } + } + // The platform's failure stands logged, the exchanges end by + // their own bound. + PeripheralEvent::Failed(reason) => { + tracing::warn!(%reason, "the platform's Bluetooth failed"); + } + } + } + } +} +/// The host's exchange rows, its outbound, outcome and bound (R76 host +/// table). +fn tick_host_exchanges( + peripheral: &Arc, + exchanges: &mut HashMap, +) -> Vec<(u64, Result)> { + let now = tokio::time::Instant::now(); + let mut ended: Vec<(u64, Result)> = Vec::new(); + for (device, exchange) in exchanges.iter_mut() { + // The exchange's outbound, to the device's notification. + while let Ok(chunk) = exchange.outbound.try_recv() { + if peripheral.notify(*device, &chunk).is_err() { + ended.push(( + *device, + Err(channel_error("the notification will not send")), + )); + break; + } + } + let outcome = match exchange.reply.try_recv() { + Ok(outcome) => Some(outcome), + Err(oneshot::error::TryRecvError::Closed) => { + Some(Err(channel_error("the exchange's task ended"))) + } + // 30 seconds pass in one exchange, so it ends with no offer. + Err(oneshot::error::TryRecvError::Empty) if now >= exchange.deadline => { + Some(Err(ExchangeError::Timeout)) + } + Err(oneshot::error::TryRecvError::Empty) => None, + }; + if let Some(outcome) = outcome { + ended.push((*device, outcome)); + } + } + for (device, outcome) in &ended { + if exchanges.remove(device).is_some() { + // The chain is refused, the handshake completes, or the bound + // runs, so the exchange ends, with or without its offer. + match outcome { + Ok(identity) => tracing::debug!(?identity, "the exchange handed its offer"), + Err(err) => tracing::debug!(%err, "the exchange ended without its offer"), + } + } + } + ended +} + +/// End the device's exchange, its stream's reader gone. +fn end_host_exchange(exchanges: &mut HashMap, device: u64) -> bool { + exchanges.remove(&device).is_some() +} + +/// One exchange, over the joiner's GATT connection (R76). +fn start_host_exchange( + node: &Node, + exchanges: &mut HashMap, + device: u64, + mtu: u16, + offer: HotspotOffer, +) { + // The MTU is a platform packet-size count, so the widening stands + // lossless. + let chunk_size = chunk_size(mtu); + let (inbound_tx, inbound_rx) = mpsc::channel(64); + let (outbound_tx, outbound_rx) = mpsc::channel(64); + let node = node.clone(); + let (reply_tx, reply_rx) = oneshot::channel(); + tokio::spawn(async move { + let io = ChunkStream::new(inbound_rx, outbound_tx, chunk_size); + let outcome = node.offer_over(io, offer_frame(&offer)).await; + let _ = reply_tx.send(outcome); + }); + exchanges.insert( + device, + HostExchange { + inbound: inbound_tx, + outbound: outbound_rx, + reply: reply_rx, + deadline: tokio::time::Instant::now() + EXCHANGE_BOUND, + }, + ); +} + +impl Machine { + /// The joiner's scan and exchange rows (R76 joiner table). + #[expect( + clippy::too_many_lines, + reason = "one tick walks the joiner table's rows in order" + )] + fn tick_joiner( + &mut self, + state: BluetoothState, + joined: bool, + serving: bool, + now: tokio::time::Instant, + ) { + // The scan's condition, decision 19, on a system whose central runs + // and whose Bluetooth stands Ready. + let should_scan = self.scan + && serving + && !joined + && state == BluetoothState::Ready + && self.central.is_some(); + + match self.joiner { + // Scan or autojoin turned on while the device serves with no + // joined hotspot. + JoinerState::Idle if should_scan => { + if let Some(central) = &self.central { + central.start_scan(); + } + self.joiner = JoinerState::Scanning; + } + // Scan turned off, the device stops serving, a hotspot is + // joined, or Bluetooth leaves Ready. + JoinerState::Scanning if !should_scan => { + if let Some(central) = &self.central { + central.stop_scan(); + } + self.report_hosts_gone(); + self.joiner = JoinerState::Idle; + } + _ => {} + } + + // Bluetooth leaves Ready, so the exchange fails with BluetoothOff. + if state != BluetoothState::Ready && matches!(self.joiner, JoinerState::Exchanging { .. }) { + self.end_join_exchange( + Err(ExchangeError::Io(io::Error::new( + io::ErrorKind::NotConnected, + "Bluetooth left Ready", + ))), + now, + ); + } + + // A host's beacon is unseen for 30 seconds, so it is reported gone. + let mut gone = Vec::new(); + for (host, record) in &self.hosts { + if now >= record.last_seen + GONE_AFTER { + gone.push(*host); + } + } + for host in gone { + self.hosts.remove(&host); + (self.emit)(ClientEvent::HostGone { host }); + } + + // The central's events. + let events = self + .central + .as_ref() + .map(|central| central.poll()) + .unwrap_or_default(); + for event in events { + let mut end = None; + match event { + // A beacon with connetto's UUID is seen. + CentralEvent::Seen { + host, + service_data, + rssi, + } => { + if let Some(beacon) = Beacon::from_service_data(&service_data) { + match self.hosts.get_mut(&host) { + None => { + self.hosts.insert( + host, + HostRecord { + prefix: beacon.prefix, + last_seen: now, + rssi: Some(rssi), + tried: false, + }, + ); + (self.emit)(ClientEvent::HostNearby { + host, + prefix: beacon.prefix, + rssi: Some(rssi), + }); + } + // A known prefix refreshes its signal. + Some(record) if record.prefix == beacon.prefix => { + record.last_seen = now; + record.rssi = Some(rssi); + } + // A new prefix is a new host, untried again. + Some(record) => { + record.prefix = beacon.prefix; + record.last_seen = now; + record.rssi = Some(rssi); + record.tried = false; + (self.emit)(ClientEvent::HostNearby { + host, + prefix: beacon.prefix, + rssi: Some(rssi), + }); + } + } + } + } + // The host connected, the exchange's stream goes live. + CentralEvent::Connected { host, mtu } => { + if matches!(&self.joiner, JoinerState::Exchanging { host: h, .. } if *h == host) + { + self.begin_join_task(mtu); + } else if let Some(central) = &self.central { + central.disconnect(host); + } + } + // A chunk the host notified, to the exchange's stream. + CentralEvent::Chunk { host, bytes } => { + if matches!(&self.joiner, JoinerState::Exchanging { host: h, .. } if *h == host) + && !self.send_join_chunk(host, bytes) + { + end = Some(channel_error("the stream's reader is gone")); + } + } + // The host disconnected, so the exchange's stream ends by its + // EOF. + CentralEvent::Disconnected { host } => { + if let JoinerState::Exchanging { + host: exchanging, + task_reply, + .. + } = &self.joiner + && *exchanging == host + { + if task_reply.is_none() { + // The stream never ran, so the exchange ends here. + end = Some(ExchangeError::Io(io::Error::new( + io::ErrorKind::NotConnected, + "the host disconnected", + ))); + } else { + // The stream ends, so the running exchange fails. + self.drop_join_inbound(); + } + } + } + } + if let Some(err) = end { + self.end_join_exchange(Err(err), now); + } + } + + // Autojoin is on and a host not yet tried is seen. + if self.autojoin + && should_scan + && matches!(self.joiner, JoinerState::Scanning) + && let Some(host) = self.strongest_untried_host() + { + self.begin_join_exchange(host, Kind::Join, None); + } + + // The exchange's rows, the outbound, the outcome, the bound. + let write_failed = { + let mut failed = false; + if let JoinerState::Exchanging { host, outbound, .. } = &mut self.joiner + && let Some(outbound) = outbound.as_mut() + { + while let Ok(chunk) = outbound.try_recv() { + let written = self + .central + .as_ref() + .map(|central| central.write(*host, &chunk)); + if !matches!(written, Some(Ok(()))) { + failed = true; + break; + } + } + } + failed + }; + let outcome = if write_failed { + Some(Err(channel_error("the central's write will not go"))) + } else { + match &mut self.joiner { + JoinerState::Exchanging { + deadline, + task_reply, + .. + } => { + match task_reply.as_mut() { + // The exchange yields its answer, or its bound runs. + Some(reply) => match reply.try_recv() { + Ok(outcome) => Some(outcome), + Err(oneshot::error::TryRecvError::Closed) => { + Some(Err(channel_error("the exchange's task ended"))) + } + Err(oneshot::error::TryRecvError::Empty) if now >= *deadline => { + Some(Err(ExchangeError::Timeout)) + } + Err(oneshot::error::TryRecvError::Empty) => None, + }, + None => None, + } + } + _ => None, + } + }; + if let Some(outcome) = outcome { + self.end_join_exchange(outcome, now); + } + } + + /// The strongest host not yet tried, for autojoin (R76 decision 19). + fn strongest_untried_host(&self) -> Option { + self.hosts + .iter() + .filter(|(_, record)| !record.tried) + .max_by_key(|(_, record)| record.rssi.unwrap_or(i16::MIN)) + .map(|(host, _)| *host) + } + + /// Report every seen host gone and forget it. + fn report_hosts_gone(&mut self) { + for host in self.hosts.keys() { + (self.emit)(ClientEvent::HostGone { host: *host }); + } + self.hosts.clear(); + } + + /// The join's exchange, from the scan or the call (R76). + fn begin_join_exchange(&mut self, host: HostId, kind: Kind, reply: Option) { + if matches!(self.joiner, JoinerState::Scanning) + && let Some(central) = &self.central + { + central.stop_scan(); + } + self.joiner = JoinerState::Exchanging { + host, + kind, + reply, + deadline: tokio::time::Instant::now() + EXCHANGE_BOUND, + inbound: None, + outbound: None, + task_reply: None, + }; + if let Some(central) = &self.central { + central.connect(host); + } + } + + /// The join's exchange, its stream live over the connected GATT (R76). + fn begin_join_task(&mut self, mtu: u16) { + let chunk_size = chunk_size(mtu); + let (inbound_tx, inbound_rx) = mpsc::channel(64); + let (outbound_tx, outbound_rx) = mpsc::channel(64); + let node = self.node.clone(); + let (reply_tx, reply_rx) = oneshot::channel(); + tokio::spawn(async move { + let io = ChunkStream::new(inbound_rx, outbound_tx, chunk_size); + let outcome = node.fetch_over(io).await; + let _ = reply_tx.send(outcome); + }); + if let JoinerState::Exchanging { + inbound, + outbound, + task_reply, + .. + } = &mut self.joiner + { + *inbound = Some(inbound_tx); + *outbound = Some(outbound_rx); + *task_reply = Some(reply_rx); + } + } + + /// A chunk to the exchange's stream, `false` where its reader is gone. + fn send_join_chunk(&mut self, host: HostId, bytes: Vec) -> bool { + let JoinerState::Exchanging { + host: target, + inbound, + .. + } = &mut self.joiner + else { + return true; + }; + if *target != host { + return true; + } + match inbound { + Some(sender) => sender.try_send(bytes).is_ok(), + None => false, + } + } + + /// The exchange's stream's reader, gone with the host's connection. + fn drop_join_inbound(&mut self) { + if let JoinerState::Exchanging { inbound, .. } = &mut self.joiner { + *inbound = None; + } + } + + /// The exchange's end, its rows applied (R76 joiner table). + fn end_join_exchange( + &mut self, + outcome: Result<(DeviceIdentity, OfferFrame), ExchangeError>, + now: tokio::time::Instant, + ) { + let JoinerState::Exchanging { + host, kind, reply, .. + } = core::mem::replace(&mut self.joiner, JoinerState::Idle) + else { + return; + }; + if let Some(central) = &self.central { + central.disconnect(host); + } + match outcome { + // The exchange yields the offer, so it goes to its kind. + Ok((_identity, frame)) => match kind { + Kind::Fetch => { + if let Some(ManualReply::Fetch(reply)) = reply { + let _ = reply.send(Ok(hotspot_offer(&frame))); + } + } + Kind::Join => { + let offer = hotspot_offer(&frame); + let (join_tx, join_rx) = oneshot::channel(); + (self.join)(offer.clone(), join_tx); + let caller = reply.and_then(|reply| match reply { + ManualReply::Join(reply) => Some(reply), + ManualReply::Fetch(_) => None, + }); + self.pending_join.push(JoinPending { + host, + reply: join_rx, + caller, + deadline: now + JOIN_BOUND + MARGIN, + }); + } + }, + // The host's chain is refused, the exchange times out, or + // Bluetooth fails, so the host is marked tried. + Err(err) => { + if let Some(record) = self.hosts.get_mut(&host) { + record.tried = true; + } + match reply { + Some(ManualReply::Fetch(reply)) => { + let _ = reply.send(Err(BluetoothError::Exchange(err))); + } + Some(ManualReply::Join(reply)) => { + let _ = reply.send(Err(JoinNearbyError::Bluetooth( + BluetoothError::Exchange(err), + ))); + } + None => { + tracing::debug!( + %err, + "the autojoin's exchange ended without its offer" + ); + } + } + } + } + } + + /// The hosted answers, once the offer and the beacon stand (R76 host + /// table). + fn answer_hosted(&mut self, state: BluetoothState) { + // The beacon's outcome, decided outside the hosted's borrow. It + // stands on the state and the host's look, not on the offer, so it + // is decided as soon as the standing allows and the answers below + // wait for the offer on its own channel. + let decision = self.hosted.as_ref().and_then(|hosted| { + if hosted.beacon.is_none() { + Some(match state { + BluetoothState::Ready + if matches!(self.host, HostBtState::Advertising { .. }) => + { + BeaconDecision::Resolve(BeaconResolution::Ok) + } + // The host's look starts the beacon, which the look + // answers. + BluetoothState::Ready => BeaconDecision::Wait, + // The standing decides the refusal, which each caller's + // answer re-derives. + // The hotspot's answer is in hand, so decision 21's + // action runs once, unless the prompt is switched off. + BluetoothState::Off | BluetoothState::NotPermitted if self.prompt => { + BeaconDecision::StartAction + } + BluetoothState::Unsupported + | BluetoothState::Off + | BluetoothState::NotPermitted => { + BeaconDecision::Resolve(BeaconResolution::Refusal { + state, + outcome: Some(PromptOutcome::NotAsked), + }) + } + }) + } else { + None + } + }); + match decision { + Some(BeaconDecision::Resolve(resolution)) => { + if let Some(hosted) = self.hosted.as_mut() { + hosted.beacon = Some(resolution); + } + } + Some(BeaconDecision::StartAction) if self.prompting.is_none() => { + // An action that will not start answers the beacon with its + // block, so the hotspot's caller is not held. + if !self.start_prompt_action() + && let Some(hosted) = self.hosted.as_mut() + { + hosted.beacon = Some(BeaconResolution::Refusal { + state, + outcome: Some(PromptOutcome::Blocked), + }); + } + } + _ => {} + } + // The answers, once the offer and the beacon stand, the refusal + // re-derived for each caller. + let (offers, replies, resolution) = { + let Some(hosted) = self.hosted.as_mut() else { + return; + }; + let Some(resolution) = hosted.beacon else { + return; + }; + ( + core::mem::take(&mut hosted.offers), + core::mem::take(&mut hosted.replies), + resolution, + ) + }; + let mut kept_offers = Vec::new(); + let mut kept_replies = Vec::new(); + for (mut offer, reply) in offers.into_iter().zip(replies) { + match offer.try_recv() { + Ok(answer) => { + let beacon = match resolution { + BeaconResolution::Ok => Ok(()), + BeaconResolution::Refusal { state, outcome } => { + Err(self.beacon_refusal(state, outcome)) + } + }; + let _ = reply.send(match answer { + Ok(offer) => Ok(Hosted { offer, beacon }), + Err(err) => Err(err), + }); + } + Err(oneshot::error::TryRecvError::Empty) => { + kept_offers.push(offer); + kept_replies.push(reply); + } + // The caller is gone, so the answer stands without it. + Err(oneshot::error::TryRecvError::Closed) => {} + } + } + if kept_offers.is_empty() { + self.hosted = None; + } else { + let Some(hosted) = self.hosted.as_mut() else { + return; + }; + hosted.offers = kept_offers; + hosted.replies = kept_replies; + } + } + + /// The beacon's refusal, re-derived for each caller's answer (R76). + fn beacon_refusal( + &self, + state: BluetoothState, + outcome: Option, + ) -> BluetoothError { + match (state, outcome) { + (BluetoothState::Unsupported, _) => BluetoothError::Unsupported, + (BluetoothState::Off, Some(outcome)) => BluetoothError::Off(outcome), + (BluetoothState::NotPermitted, Some(outcome)) => BluetoothError::NotPermitted { + missing: self.readiness.missing_permissions(), + outcome, + }, + // The Ready standing is never a refusal, so that defensive row + // stands on the bound's own reason. + (_, _) => BluetoothError::TimedOut, + } + } + + /// The joined answers, once the join machine answers them (R76 joiner + /// table). + fn answer_joined(&mut self, now: tokio::time::Instant) { + let mut done: Vec<(usize, Result)> = Vec::new(); + for (index, pending) in self.pending_join.iter_mut().enumerate() { + let answer = match pending.reply.try_recv() { + Ok(answer) => Some(answer), + Err(oneshot::error::TryRecvError::Closed) => Some(Err(JoinError::Failed)), + Err(oneshot::error::TryRecvError::Empty) if now >= pending.deadline => { + Some(Err(JoinError::TimedOut)) + } + Err(oneshot::error::TryRecvError::Empty) => None, + }; + if let Some(answer) = answer { + done.push((index, answer)); + } + } + for (index, answer) in done { + let pending = self.pending_join.remove(index); + // The join is declined or times out, so the host is marked tried. + if let Err(err) = &answer + && matches!(err, JoinError::Declined | JoinError::TimedOut) + && let Some(record) = self.hosts.get_mut(&pending.host) + { + record.tried = true; + } + if let Some(caller) = pending.caller { + let _ = caller.send(match answer { + Ok(gateway) => Ok(gateway), + Err(err) => Err(JoinNearbyError::Join(err)), + }); + } + } + } + + /// The beacon's standing, for the panel (R76). + fn update_beacon(&mut self, state: BluetoothState, hosting: bool, serving: bool) { + let beacon = match &self.host { + HostBtState::Advertising { prefix, .. } => BeaconState::Advertising { prefix: *prefix }, + HostBtState::Off => BeaconState::Off { + reason: Self::beacon_reason(state, hosting, serving), + }, + }; + if *self.beacon.borrow() != beacon { + self.beacon.send_replace(beacon); + } + } + + /// Apply the "Client closed" rows, for the loop's end and a dropped + /// client (R76). + pub(crate) fn close(&mut self) { + // The host's service stops, the joiner's scan and exchange cancel. + self.peripheral.stop(); + if matches!(self.joiner, JoinerState::Scanning) + && let Some(central) = &self.central + { + central.stop_scan(); + } + self.host = HostBtState::Off; + self.joiner = JoinerState::Idle; + self.hosts.clear(); + self.hosted = None; + self.pending_join.clear(); + self.prompting = None; + self.action_waiters.clear(); + // The beacon's standing settles, for the panel. + let state = self.readiness.state(); + self.update_beacon(state, false, (self.serving)().is_some()); + } + + /// Whether an exchange runs, so the loop's pace tightens. + pub(crate) fn exchange_running(&self) -> bool { + matches!(self.joiner, JoinerState::Exchanging { .. }) + || matches!( + self.host, + HostBtState::Advertising { ref exchanges, .. } if !exchanges.is_empty() + ) + } +} + +/// The machine's commands, from the client's API (R76). +pub(crate) enum Command { + /// Enable Bluetooth, the platform's action inside the call (R76 decision + /// 21). + Enable(Reply>), + /// Host the hotspot, the runner linking the hotspot machine's answer. + Host(Reply>), + /// Join the nearby host's hotspot through the exchange (R76 decision + /// 19). + JoinNearby { + /// The host to join. + host: HostId, + /// The answer, once the network is up. + reply: Reply>, + }, + /// Fetch the nearby host's offer through the exchange, without joining + /// (R76 decision 19). + Fetch { + /// The host to fetch. + host: HostId, + /// The answer, once the exchange yields the offer. + reply: Reply>, + }, + /// The scan's standing, at run time (R76 decision 19). + SetScan(bool), + /// The autojoin's standing, at run time, implying the scan (R76 decision + /// 19). + SetAutojoin(bool), + /// The prompt's standing, at run time (R76 decision 21). + SetPrompt(bool), +} + +/// The one inbound the loop looks at, from either machine's channel or its +/// own pace. +enum Next { + Hotspot(Option), + Bluetooth(Option), + Tick, +} + +/// The machines' loop beside the client's pump, ending with it (R76). +pub(crate) struct Runner { + future: Pin + Send>>, + hotspot: Arc, + peripheral: Arc, + central: Option>, +} + +impl Future for Runner { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<()> { + self.future.as_mut().poll(cx) + } +} + +impl Drop for Runner { + fn drop(&mut self) { + // A dropped client leaves the machines unpollable, so the tables' + // "Client closed" rows apply here, where they are no-ops on the + // backends' own standing. + self.hotspot.stop_host(); + self.hotspot.leave_join(); + self.peripheral.stop(); + if let Some(central) = &self.central { + central.stop_scan(); + } + } +} + +/// The machines beside the client's pump, the backends' stop and leave +/// applying to a dropped client (R76). +pub(crate) fn run( + mut hotspot: crate::hotspot::Machine, + mut hotspot_commands: mpsc::UnboundedReceiver, + mut bluetooth: Machine, + mut bluetooth_commands: mpsc::UnboundedReceiver, +) -> Runner { + let hotspot_backend = Arc::clone(&hotspot.backend); + let peripheral = Arc::clone(&bluetooth.peripheral); + let central = bluetooth.central.clone(); + Runner { + future: Box::pin(async move { + let mut hotspot_closed = false; + let mut bluetooth_closed = false; + loop { + let pace = if bluetooth.exchange_running() { + FAST_TICK + } else { + TICK + }; + let next = tokio::select! { + command = hotspot_commands.recv() => Next::Hotspot(command), + command = bluetooth_commands.recv() => Next::Bluetooth(command), + () = tokio::time::sleep(pace) => Next::Tick, + }; + match next { + Next::Hotspot(Some(command)) => hotspot.handle(command), + Next::Hotspot(None) => hotspot_closed = true, + Next::Bluetooth(Some(command)) => match command { + // The caller's answer stands linked to the beacon's, + // beside the hotspot machine's offer. + Command::Host(caller) => { + let (offer_tx, offer_rx) = oneshot::channel(); + hotspot.handle(crate::hotspot::Command::Host(offer_tx)); + bluetooth.hosted(offer_rx, caller); + } + command => bluetooth.handle(command), + }, + Next::Bluetooth(None) => bluetooth_closed = true, + Next::Tick => { + // The hotspot's standing, read before either tick, so + // the beacon's rows stand on the hotspot's own look. + let hosting = hotspot.hosting_offer(); + let joined = hotspot.joined(); + hotspot.tick(); + bluetooth.tick(hosting.as_ref(), joined); + } + } + // A closed channel ends the loop, both of them. + if hotspot_closed && bluetooth_closed { + hotspot.close(); + bluetooth.close(); + break; + } + } + }), + hotspot: hotspot_backend, + peripheral, + central, + } +} +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::net::SocketAddr; + use std::time::{SystemTime, UNIX_EPOCH}; + + use connetto_core::device_cert::{ + AttestationLevel, CertificateRequest, CertificateSigner, DeploymentId, DeviceCertificate, + DeviceIssuer, DeviceKey, DeviceKeyError, KeyHome, RootCa, public_key_info, + }; + use connetto_peer::{Identity, PeerEvent, SystemClock, Trust}; + use parking_lot::Mutex; + use rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256, SigningKey}; + + /// The offers the join seam received, with their replies. + type Joins = Arc>)>>>; + + const DAY: Duration = Duration::from_hours(24); + + fn whole_second() -> SystemTime { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("after the epoch") + .as_secs(); + UNIX_EPOCH + Duration::from_secs(secs) + } + + fn all_levels() -> Vec { + AttestationLevel::ALL.into_iter().collect() + } + + /// A device key held in memory, standing in for a chip. + struct MemKey(Arc); + + impl DeviceKey for MemKey { + fn public_point(&self) -> [u8; 65] { + self.0 + .public_key_raw() + .try_into() + .expect("an uncompressed P-256 point") + } + + fn sign(&self, message: &[u8]) -> Result, DeviceKeyError> { + self.0 + .sign(message) + .map_err(|err| DeviceKeyError::Platform(Box::new(err))) + } + + fn home(&self) -> KeyHome { + KeyHome::Software + } + } + + /// A root and the two device identities it signs, under one issuer. + struct Deployment { + root: Vec, + host_leaf: Vec, + host_identity: Identity, + joiner_leaf: Vec, + joiner_identity: Identity, + } + + impl Deployment { + fn new() -> Self { + let now = whole_second(); + let root = RootCa::create( + DeploymentId::from_uuid(uuid::Uuid::from_u128(1)), + now - 10 * DAY, + 3650 * DAY, + ) + .expect("the root mints"); + let root_der = root.certificate().to_vec(); + let issuer_key: Arc = + Arc::new(KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("the issuer key")); + let issuer_holder = MemKey(Arc::clone(&issuer_key)); + let spki = public_key_info(&issuer_holder); + let issuer_cert = root + .sign_issuer(&spki, now - 10 * DAY, 366 * DAY, [1; 16]) + .expect("the issuer signs"); + let issuer_pair = KeyPair::from_pkcs8_der_and_sign_algo( + &rustls::pki_types::PrivatePkcs8KeyDer::from(issuer_key.serialize_der()), + &PKCS_ECDSA_P256_SHA256, + ) + .expect("the issuer key round-trips"); + let signer = DeviceIssuer::new(issuer_cert.clone(), issuer_pair, &root_der) + .expect("the issuer loads"); + let (host_leaf, host_identity) = + Self::device(&signer, &issuer_cert, "host", now, [1; 16]); + let (joiner_leaf, joiner_identity) = + Self::device(&signer, &issuer_cert, "joiner", now, [2; 16]); + Self { + root: root_der, + host_leaf, + host_identity, + joiner_leaf, + joiner_identity, + } + } + + fn device( + signer: &DeviceIssuer, + issuer_cert: &[u8], + account: &str, + now: SystemTime, + serial: [u8; 16], + ) -> (Vec, Identity) { + let key = MemKey(Arc::new( + KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("the device key"), + )); + let csr = CertificateRequest::build(&CertificateSigner::new(&key), &[1; 32]) + .expect("the csr"); + let request = CertificateRequest::parse(&csr).expect("the request parses"); + let leaf = signer + .issue( + &request, + account, + now, + DAY, + serial, + AttestationLevel::Unproven, + ) + .expect("the certificate issues"); + DeviceCertificate::parse(&leaf).expect("the profile holds"); + let identity = Identity { + certificate: leaf.clone(), + issuer: issuer_cert.to_vec(), + key: Arc::new(key), + }; + (leaf, identity) + } + } + + /// A node that trusts `root_der` and serves `identity` on loopback. + fn serving_node(root_der: Vec, identity: Identity) -> Node { + let (tx, _rx) = mpsc::unbounded_channel::(); + let node = Node::new( + Trust { + roots: vec![root_der], + accepted: all_levels(), + }, + Arc::new(SystemClock), + tx, + ) + .expect("the roots hold keys"); + node.serve(SocketAddr::from(([127, 0, 0, 1], 0)), identity) + .expect("the listener binds"); + node + } + + /// A node with no identity, so its exchanges answer `NotServing`. + fn quiet_node() -> Node { + let (tx, _rx) = mpsc::unbounded_channel::(); + Node::new( + Trust { + roots: Vec::new(), + accepted: Vec::new(), + }, + Arc::new(SystemClock), + tx, + ) + .expect("a node with no roots") + } + + /// A readiness the test steers, recording its prompt. + struct FakeReadiness { + state: Mutex, + missing: Mutex>, + prompt_calls: Mutex, + outcome: Mutex>, + /// Whether the platform's action refuses to start. + blocked: Mutex, + } + + impl FakeReadiness { + fn new(state: BluetoothState) -> Self { + Self { + state: Mutex::new(state), + missing: Mutex::new(Vec::new()), + prompt_calls: Mutex::new(0), + outcome: Mutex::new(None), + blocked: Mutex::new(false), + } + } + } + + impl ReadinessBackend for FakeReadiness { + fn state(&self) -> BluetoothState { + *self.state.lock() + } + + fn missing_permissions(&self) -> Vec { + self.missing.lock().clone() + } + + fn prompt(&self) -> Result<(), BluetoothError> { + *self.prompt_calls.lock() += 1; + if *self.blocked.lock() { + return Err(BluetoothError::Failed("the action is blocked".into())); + } + Ok(()) + } + + fn prompt_outcome(&self) -> Option { + *self.outcome.lock() + } + } + + /// The wire the host's notifications and the joiner's writes cross. + struct Wire { + to_joiner: Mutex)>>, + to_host: Mutex)>>, + } + + impl Wire { + fn new() -> Self { + Self { + to_joiner: Mutex::new(VecDeque::new()), + to_host: Mutex::new(VecDeque::new()), + } + } + } + + /// A GATT service the test steers, bridging its notifications to the wire. + struct FakePeripheral { + wire: Arc, + started: Mutex>, + stopped: Mutex, + disconnects: Mutex>, + notifies: Mutex>, + queue: Mutex>, + } + + impl FakePeripheral { + fn new(wire: Arc) -> Self { + Self { + wire, + started: Mutex::new(Vec::new()), + stopped: Mutex::new(0), + disconnects: Mutex::new(Vec::new()), + notifies: Mutex::new(Vec::new()), + queue: Mutex::new(VecDeque::new()), + } + } + + fn push(&self, event: PeripheralEvent) { + self.queue.lock().push_back(event); + } + } + + impl PeripheralBackend for FakePeripheral { + fn start(&self, beacon: [u8; 9]) -> Result<(), BluetoothError> { + self.started.lock().push(beacon); + Ok(()) + } + + fn stop(&self) { + *self.stopped.lock() += 1; + } + + fn poll(&self) -> Vec { + let mut out: Vec = self.queue.lock().drain(..).collect(); + for (device, bytes) in self.wire.to_host.lock().drain(..) { + out.push(PeripheralEvent::Chunk { device, bytes }); + } + out + } + + fn notify(&self, device: u64, bytes: &[u8]) -> Result<(), BluetoothError> { + self.notifies.lock().push((device, bytes.len())); + self.wire + .to_joiner + .lock() + .push_back((device, bytes.to_vec())); + Ok(()) + } + + fn disconnect(&self, device: u64) { + self.disconnects.lock().push(device); + } + } + + /// A central the test steers, bridging its writes to the wire. + struct FakeCentral { + wire: Arc, + scans: Mutex, + /// Every scan start, so a flapping scan shows. + starts: Mutex, + connects: Mutex>, + disconnects: Mutex>, + queue: Mutex>, + } + + impl FakeCentral { + fn new(wire: Arc) -> Self { + Self { + wire, + scans: Mutex::new(0), + starts: Mutex::new(0), + connects: Mutex::new(Vec::new()), + disconnects: Mutex::new(Vec::new()), + queue: Mutex::new(VecDeque::new()), + } + } + + fn push(&self, event: CentralEvent) { + self.queue.lock().push_back(event); + } + + fn started(&self) -> bool { + *self.scans.lock() > 0 + } + } + + impl CentralBackend for FakeCentral { + fn start_scan(&self) { + *self.scans.lock() += 1; + *self.starts.lock() += 1; + } + + fn stop_scan(&self) { + *self.scans.lock() -= 1; + } + + fn poll(&self) -> Vec { + let mut out: Vec = self.queue.lock().drain(..).collect(); + for (host, bytes) in self.wire.to_joiner.lock().drain(..) { + out.push(CentralEvent::Chunk { host, bytes }); + } + out + } + + fn connect(&self, host: HostId) { + self.connects.lock().push(host); + } + + fn write(&self, host: HostId, bytes: &[u8]) -> Result<(), BluetoothError> { + self.wire.to_host.lock().push_back((host, bytes.to_vec())); + Ok(()) + } + + fn disconnect(&self, host: HostId) { + self.disconnects.lock().push(host); + } + } + + /// The machine over the fakes, with its seams' records. + struct Harness { + machine: Machine, + readiness: Arc, + peripheral: Arc, + central: Arc, + serving: Arc>>, + events: Arc>>, + beacon: watch::Receiver, + } + + fn fp(seed: u8) -> Fingerprint { + Fingerprint::of(&[seed; 32]) + } + + fn offer() -> HotspotOffer { + HotspotOffer::new("hotspot", "secret", HotspotSecurity::Wpa2, Some(54321)) + } + + impl Harness { + fn new(state: BluetoothState) -> Self { + Self::with(state, quiet_node(), true, false, true, Some(fp(1))) + } + + fn with( + state: BluetoothState, + node: Node, + scan: bool, + autojoin: bool, + prompt: bool, + serving: Option, + ) -> Self { + let wire = Arc::new(Wire::new()); + let readiness = Arc::new(FakeReadiness::new(state)); + let peripheral = Arc::new(FakePeripheral::new(wire.clone())); + let central = Arc::new(FakeCentral::new(wire)); + let serving = Arc::new(Mutex::new(serving)); + let scan = Arc::new(Mutex::new(scan)); + let autojoin = Arc::new(Mutex::new(autojoin)); + let prompt = Arc::new(Mutex::new(prompt)); + let joins: Joins = Arc::new(Mutex::new(Vec::new())); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (machine, beacon) = Machine::new( + Arc::clone(&readiness) as Arc, + Arc::clone(&peripheral) as Arc, + Some(Arc::clone(¢ral) as Arc), + node, + *scan.lock(), + *autojoin.lock(), + *prompt.lock(), + Arc::new({ + let serving = Arc::clone(&serving); + move || *serving.lock() + }), + Arc::new({ + let joins = Arc::clone(&joins); + move |offer, reply| joins.lock().push((offer, reply)) + }), + Arc::new({ + let events = Arc::clone(&events); + move |event| events.lock().push(event) + }), + ); + Self { + machine, + readiness, + peripheral, + central, + serving, + events, + beacon, + } + } + + fn beacon_state(&self) -> BeaconState { + self.beacon.borrow().clone() + } + + fn tick(&mut self, hosting: Option<&HotspotOffer>) { + self.machine.tick(hosting, false); + } + + fn host_seen(&self, host: HostId, host_fp: &Fingerprint, rssi: i16) { + self.central.push(CentralEvent::Seen { + host, + service_data: Beacon::of(host_fp).to_service_data().to_vec(), + rssi, + }); + } + + fn host_nearby(&self) -> Vec<(HostId, [u8; 8])> { + self.events + .lock() + .iter() + .filter_map(|event| match event { + ClientEvent::HostNearby { host, prefix, .. } => Some((*host, *prefix)), + _ => None, + }) + .collect() + } + + fn host_gone(&self) -> Vec { + self.events + .lock() + .iter() + .filter_map(|event| match event { + ClientEvent::HostGone { host } => Some(*host), + _ => None, + }) + .collect() + } + } + + type JoinReply = oneshot::Sender>; + + impl Harness { + fn strongest_untried(&self) -> Option { + self.machine.strongest_untried_host() + } + } + + /// The host and joiner machines over one wire, each with a serving node. + struct ExchangePair { + host: Machine, + host_peripheral: Arc, + joiner: Machine, + joiner_central: Arc, + joins: Arc>>, + host_fp: Fingerprint, + } + + impl ExchangePair { + fn new() -> Self { + let d = Deployment::new(); + let wire = Arc::new(Wire::new()); + // The host plays no joiner and the joiner plays no host, so their + // unused roles drain a wire nothing writes to, never the live one. + let dead_wire = Arc::new(Wire::new()); + let host_fp = Fingerprint::of(&d.host_leaf); + let host_node = serving_node(d.root.clone(), d.host_identity); + let joiner_node = serving_node(d.root.clone(), d.joiner_identity); + let h_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); + let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); + let h_central = Arc::new(FakeCentral::new(dead_wire.clone())); + let h_serving = Arc::new(Mutex::new(Some(host_fp))); + let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (host, _h_beacon) = Machine::new( + h_readiness, + h_peripheral.clone(), + Some(h_central as Arc), + host_node, + false, + false, + true, + Arc::new({ + let serving = Arc::clone(&h_serving); + move || *serving.lock() + }), + Arc::new(move |_offer, _reply| {}), + Arc::new({ + let events = Arc::clone(&h_events); + move |event| events.lock().push(event) + }), + ); + let j_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); + let j_peripheral = Arc::new(FakePeripheral::new(dead_wire.clone())); + let j_central = Arc::new(FakeCentral::new(wire.clone())); + let j_serving = Arc::new(Mutex::new(Some(Fingerprint::of(&d.joiner_leaf)))); + let joins: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let j_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (joiner, _j_beacon) = Machine::new( + j_readiness, + j_peripheral, + Some(j_central.clone() as Arc), + joiner_node, + true, + false, + true, + Arc::new({ + let serving = Arc::clone(&j_serving); + move || *serving.lock() + }), + Arc::new({ + let joins = Arc::clone(&joins); + move |offer, reply| joins.lock().push((offer, reply)) + }), + Arc::new({ + let events = Arc::clone(&j_events); + move |event| events.lock().push(event) + }), + ); + Self { + host, + host_peripheral: h_peripheral, + joiner, + joiner_central: j_central, + joins, + host_fp, + } + } + + /// Advertise the host's beacon and pump until `done` stands. + async fn pump_until(&mut self, mut done: impl FnMut() -> bool) { + let mut p = 0; + self.host.tick(Some(&offer()), false); + loop { + if done() { + break; + } + p += 1; + assert!(p < 2000, "the exchange did not complete in time"); + self.host.tick(Some(&offer()), false); + self.joiner.tick(None, false); + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + } + + // --- The host's beacon table --- + + #[test] + fn a_chunk_fits_both_the_packet_and_the_longest_attribute() { + assert_eq!(chunk_size(23), 20); + assert_eq!(chunk_size(185), 182); + assert_eq!(chunk_size(517), 512); + assert_eq!(chunk_size(0), 1); + } + + #[tokio::test] + async fn a_hosting_serving_device_starts_its_beacon() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + let prefix = fp(1).prefix(); + assert_eq!(h.beacon_state(), BeaconState::Advertising { prefix }); + } + + #[tokio::test] + async fn a_stopped_hotspot_stops_the_beacon() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + // The hotspot stops, so the beacon stops with it. + h.tick(None); + assert_eq!(*h.peripheral.stopped.lock(), 1); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + } + + #[tokio::test] + async fn a_renewal_readvertises_under_its_new_prefix() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + // A renewal changes the presented leaf, so the beacon re-advertises. + *h.serving.lock() = Some(fp(2)); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 2); + let prefix = fp(2).prefix(); + assert_eq!(h.beacon_state(), BeaconState::Advertising { prefix }); + } + + #[tokio::test] + async fn a_joiner_connecting_beneath_the_cap_runs_its_exchange() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + h.peripheral.push(PeripheralEvent::Connected { + device: 7, + mtu: 517, + }); + h.tick(Some(&offer())); + // The exchange runs, so the platform is left connected. + assert!(h.peripheral.disconnects.lock().is_empty()); + // Its NotServing outcome ends it on the next look. + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.disconnects.lock(), vec![7]); + } + + #[tokio::test] + async fn a_fifth_connection_is_disconnected_at_once() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + for device in 1..=4 { + h.peripheral + .push(PeripheralEvent::Connected { device, mtu: 517 }); + } + h.tick(Some(&offer())); + // Four exchanges run, so the fifth is disconnected at once. + h.peripheral.push(PeripheralEvent::Connected { + device: 5, + mtu: 517, + }); + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.disconnects.lock(), vec![5]); + } + + #[tokio::test] + async fn a_refused_chain_ends_its_exchange_without_an_offer() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + h.peripheral.push(PeripheralEvent::Connected { + device: 7, + mtu: 517, + }); + h.tick(Some(&offer())); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(Some(&offer())); + // The exchange ends, and no offer was sent. + assert_eq!(*h.peripheral.disconnects.lock(), vec![7]); + assert!(h.peripheral.notifies.lock().is_empty()); + } + + #[tokio::test(start_paused = true)] + async fn a_stalled_exchange_ends_at_its_bound() { + let d = Deployment::new(); + let node = serving_node(d.root.clone(), d.host_identity); + let mut h = Harness::with(BluetoothState::Ready, node, true, false, true, Some(fp(1))); + h.tick(Some(&offer())); + h.peripheral.push(PeripheralEvent::Connected { + device: 7, + mtu: 517, + }); + h.tick(Some(&offer())); + // No joiner answers, so the exchange runs out its bound. + tokio::time::sleep(EXCHANGE_BOUND + Duration::from_secs(1)).await; + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.disconnects.lock(), vec![7]); + } + + #[tokio::test] + async fn bluetooth_leaving_ready_stops_the_beacon() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + *h.readiness.state.lock() = BluetoothState::Off; + h.tick(Some(&offer())); + assert_eq!(*h.peripheral.stopped.lock(), 1); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + } + + #[tokio::test] + async fn bluetooth_becoming_ready_starts_the_beacon() { + let mut h = Harness::new(BluetoothState::Off); + h.tick(Some(&offer())); + assert!(h.peripheral.started.lock().is_empty()); + *h.readiness.state.lock() = BluetoothState::Ready; + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + } + + #[tokio::test] + async fn a_host_hotspot_without_ready_bluetooth_answers_its_beacon_outcome() { + let mut h = Harness::new(BluetoothState::Off); + let (offer_tx, offer_rx) = oneshot::channel(); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.hosted(offer_rx, reply_tx); + h.tick(Some(&offer())); + // Decision 21's action ran once, and the hotspot's answer went in. + assert_eq!(*h.readiness.prompt_calls.lock(), 1); + let _ = offer_tx.send(Ok(offer())); + // The user declined, so the beacon's outcome is a refusal. + *h.readiness.outcome.lock() = Some(PromptOutcome::Declined); + h.tick(Some(&offer())); + let hosted = reply_rx.try_recv().expect("the caller is answered"); + let Hosted { offer, beacon } = hosted.expect("the hotspot started"); + assert_eq!(offer.ssid, "hotspot"); + assert!(matches!( + beacon, + Err(BluetoothError::Off(PromptOutcome::Declined)) + )); + } + + #[tokio::test] + async fn a_host_hotspot_whose_action_will_not_start_answers_blocked_at_once() { + let mut h = Harness::new(BluetoothState::Off); + *h.readiness.blocked.lock() = true; + let (offer_tx, offer_rx) = oneshot::channel(); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.hosted(offer_rx, reply_tx); + let _ = offer_tx.send(Ok(offer())); + h.tick(Some(&offer())); + h.tick(Some(&offer())); + // The action was tried once, and its refusal answers the caller. + assert_eq!(*h.readiness.prompt_calls.lock(), 1); + let Hosted { beacon, .. } = reply_rx + .try_recv() + .expect("the caller is answered") + .expect("the hotspot started"); + assert!(matches!( + beacon, + Err(BluetoothError::Off(PromptOutcome::Blocked)) + )); + } + + #[tokio::test] + async fn a_closed_client_stops_the_beacon() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(Some(&offer())); + assert_eq!(h.peripheral.started.lock().len(), 1); + h.machine.close(); + assert_eq!(*h.peripheral.stopped.lock(), 1); + assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); + } + + // --- The joiner's scan and exchange table --- + + #[tokio::test] + async fn scan_on_while_serving_starts_scanning() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + assert!(h.central.started()); + } + + #[tokio::test] + async fn scan_off_stops_scanning_and_reports_its_hosts_gone() { + let mut h = Harness::new(BluetoothState::Ready); + let host_fp = fp(9); + h.tick(None); + h.host_seen(1, &host_fp, -50); + h.tick(None); + assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); + h.machine.handle(Command::SetScan(false)); + h.tick(None); + assert!(!h.central.started()); + assert_eq!(h.host_gone(), vec![1]); + } + + #[tokio::test] + async fn a_new_beacon_reports_host_nearby_and_a_known_one_refreshes() { + let mut h = Harness::new(BluetoothState::Ready); + let host_fp = fp(9); + h.tick(None); + h.host_seen(1, &host_fp, -50); + h.tick(None); + assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); + // A known prefix refreshes its signal, with no new event. + h.host_seen(1, &host_fp, -60); + h.tick(None); + assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); + } + + #[tokio::test(start_paused = true)] + async fn an_unseen_beacon_is_reported_gone_after_its_bound() { + let mut h = Harness::new(BluetoothState::Ready); + let host_fp = fp(9); + h.tick(None); + h.host_seen(1, &host_fp, -50); + h.tick(None); + assert_eq!(h.host_gone(), Vec::::new()); + tokio::time::sleep(GONE_AFTER + Duration::from_secs(1)).await; + h.tick(None); + assert_eq!(h.host_gone(), vec![1]); + } + + #[tokio::test] + async fn autojoin_picks_the_strongest_untried_host() { + let mut h = Harness::with( + BluetoothState::Ready, + quiet_node(), + true, + true, + true, + Some(fp(1)), + ); + let weak = fp(10); + let strong = fp(11); + h.tick(None); + h.host_seen(1, &weak, -80); + h.host_seen(2, &strong, -40); + h.tick(None); + assert_eq!(*h.central.connects.lock(), vec![2]); + } + + #[tokio::test] + async fn a_join_without_ready_bluetooth_runs_its_action_once() { + let mut h = Harness::new(BluetoothState::Off); + let (reply_tx, _reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + h.tick(None); + assert_eq!(*h.readiness.prompt_calls.lock(), 1); + // The user approves, so the radio stands Ready and the exchange goes. + *h.readiness.state.lock() = BluetoothState::Ready; + *h.readiness.outcome.lock() = Some(PromptOutcome::NotAsked); + h.tick(None); + assert_eq!(*h.readiness.prompt_calls.lock(), 1); + assert_eq!(*h.central.connects.lock(), vec![1]); + } + + #[tokio::test] + async fn a_system_without_a_central_neither_scans_nor_joins() { + let mut h = Harness::new(BluetoothState::Ready); + h.machine.central = None; + h.tick(None); + h.tick(None); + assert_eq!(*h.central.starts.lock(), 0); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + assert!(matches!( + reply_rx.try_recv(), + Ok(Err(BluetoothError::Unsupported)) + )); + assert_eq!(*h.readiness.prompt_calls.lock(), 0); + } + + #[tokio::test] + async fn a_manual_join_runs_its_exchange_from_its_state() { + let mut h = Harness::new(BluetoothState::Ready); + let (first_tx, _first_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: first_tx, + }); + h.tick(None); + assert_eq!(*h.central.connects.lock(), vec![1]); + // An exchange runs, so a second call answers Busy. + let (busy_tx, mut busy_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 2, + reply: busy_tx, + }); + h.tick(None); + let answer = busy_rx.try_recv().expect("the caller is answered"); + assert!(matches!(answer, Err(BluetoothError::Busy))); + } + + #[tokio::test] + async fn a_refused_or_failed_exchange_marks_its_host_tried() { + let mut h = Harness::new(BluetoothState::Ready); + let host_fp = fp(9); + h.tick(None); + h.host_seen(1, &host_fp, -50); + h.tick(None); + // A manual fetch, whose NotServing exchange ends refused. + let (reply_tx, _reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + h.tick(None); + h.central + .push(CentralEvent::Connected { host: 1, mtu: 517 }); + h.tick(None); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(None); + // The host is marked tried, so it is no longer picked. + assert!(h.strongest_untried().is_none()); + } + + #[tokio::test] + async fn a_host_that_disconnects_ends_its_exchange_at_once() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + h.host_seen(1, &fp(9), -50); + h.tick(None); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + h.tick(None); + assert_eq!(*h.central.connects.lock(), vec![1]); + // The host drops the connection before it settles. + h.central.push(CentralEvent::Disconnected { host: 1 }); + h.tick(None); + tokio::task::yield_now().await; + h.tick(None); + assert!(matches!(reply_rx.try_recv(), Ok(Err(_)))); + assert!(h.strongest_untried().is_none()); + } + + #[tokio::test] + async fn a_declined_join_marks_its_host_tried() { + let mut pair = ExchangePair::new(); + let device: HostId = 1; + // The host's beacon is seen, so it is tracked and untried. + pair.joiner_central.push(CentralEvent::Seen { + host: device, + service_data: Beacon::of(&pair.host_fp).to_service_data().to_vec(), + rssi: -40, + }); + pair.joiner.tick(None, false); + let (join_tx, mut join_rx) = oneshot::channel(); + pair.joiner.handle(Command::JoinNearby { + host: device, + reply: join_tx, + }); + pair.joiner_central.push(CentralEvent::Connected { + host: device, + mtu: 517, + }); + pair.host_peripheral + .push(PeripheralEvent::Connected { device, mtu: 517 }); + let joins = Arc::clone(&pair.joins); + pair.pump_until(|| !joins.lock().is_empty()).await; + let (_offer, join_seam) = joins.lock().remove(0); + // The user declines the join, so the host is marked tried. + let _ = join_seam.send(Err(JoinError::Declined)); + pair.joiner.tick(None, false); + let answer = join_rx.try_recv().expect("the caller is answered"); + assert!(matches!( + answer, + Err(JoinNearbyError::Join(JoinError::Declined)) + )); + assert!(pair.joiner.strongest_untried_host().is_none()); + } + + #[tokio::test] + async fn bluetooth_leaving_ready_fails_its_exchange() { + let mut h = Harness::new(BluetoothState::Ready); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + h.tick(None); + // The exchange's stream goes live. + h.central + .push(CentralEvent::Connected { host: 1, mtu: 517 }); + h.tick(None); + // Bluetooth leaves Ready, so the exchange fails with BluetoothOff. + *h.readiness.state.lock() = BluetoothState::Off; + h.tick(None); + let answer = reply_rx.try_recv().expect("the caller is answered"); + assert!(matches!( + answer, + Err(BluetoothError::Exchange(ExchangeError::Io(_))) + )); + } + + #[tokio::test] + async fn bluetooth_becoming_ready_starts_its_scan() { + let mut h = Harness::new(BluetoothState::Off); + h.tick(None); + h.tick(None); + // The scan does not start while the radio stands Off. + assert_eq!(*h.central.starts.lock(), 0); + *h.readiness.state.lock() = BluetoothState::Ready; + h.tick(None); + assert!(h.central.started()); + } + + #[tokio::test] + async fn a_closed_client_stops_its_scan_and_exchange() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + assert!(h.central.started()); + // An exchange runs beneath the scan. + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + h.tick(None); + assert_eq!(*h.central.connects.lock(), vec![1]); + // The client closes, so the scan stops and the exchange cancels. + h.machine.close(); + assert!(!h.central.started()); + // The cancelled exchange's answer is dropped with it. + assert!(reply_rx.try_recv().is_err()); + } + + // --- The exchange's offer, over the machines --- + + #[tokio::test] + async fn a_completed_exchange_sends_its_offer_and_ends() { + let mut pair = ExchangePair::new(); + let device: HostId = 1; + let (fetch_tx, _fetch_rx) = oneshot::channel(); + pair.joiner.handle(Command::Fetch { + host: device, + reply: fetch_tx, + }); + pair.joiner_central.push(CentralEvent::Connected { + host: device, + mtu: 517, + }); + pair.host_peripheral + .push(PeripheralEvent::Connected { device, mtu: 517 }); + let host_peripheral = Arc::clone(&pair.host_peripheral); + pair.pump_until(|| host_peripheral.disconnects.lock().contains(&device)) + .await; + // The host sent its offer over the link, then ended the exchange. + assert!(!pair.host_peripheral.notifies.lock().is_empty()); + assert!(pair.host_peripheral.disconnects.lock().contains(&device)); + } + + #[tokio::test] + async fn an_exchanged_offer_reaches_its_caller_and_the_join() { + let mut pair = ExchangePair::new(); + let device: HostId = 1; + let (join_tx, mut join_rx) = oneshot::channel(); + pair.joiner.handle(Command::JoinNearby { + host: device, + reply: join_tx, + }); + pair.joiner_central.push(CentralEvent::Connected { + host: device, + mtu: 517, + }); + pair.host_peripheral + .push(PeripheralEvent::Connected { device, mtu: 517 }); + let joins = Arc::clone(&pair.joins); + pair.pump_until(|| !joins.lock().is_empty()).await; + // The offer reached the join's seam. + let (offer, join_seam) = pair.joins.lock().remove(0); + assert_eq!(offer.ssid, "hotspot"); + assert_eq!(offer.passphrase, "secret"); + // The join answers its gateway, so the caller gets it. + let _ = join_seam.send(Ok("192.168.43.1".parse().unwrap())); + pair.joiner.tick(None, false); + let gateway: IpAddr = "192.168.43.1".parse().unwrap(); + let answer = join_rx.try_recv(); + let Ok(Ok(answered)) = answer else { + panic!("the join answers its gateway, got {answer:?}") + }; + assert_eq!(answered, gateway); + } + + // --- The unified runner --- + + /// A hotspot backend whose host stands Started, so the runner's host + /// row answers its offer. + struct FakeHotspotBackend { + host_status: Mutex, + } + + impl FakeHotspotBackend { + fn new() -> Self { + Self { + host_status: Mutex::new(crate::hotspot::HostStatus::Started { + ssid: "hotspot".into(), + passphrase: "secret".into(), + security: HotspotSecurity::Wpa2, + }), + } + } + } + + impl crate::hotspot::HotspotBackend for FakeHotspotBackend { + fn request_host(&self) -> Result<(), HotspotError> { + Ok(()) + } + + fn stop_host(&self) {} + + fn host_status(&self) -> crate::hotspot::HostStatus { + self.host_status.lock().clone() + } + + fn request_join(&self, _offer: &HotspotOffer) -> Result<(), JoinError> { + Ok(()) + } + + fn leave_join(&self) {} + + fn join_status(&self) -> crate::hotspot::JoinStatus { + crate::hotspot::JoinStatus::Pending + } + } + + #[tokio::test(start_paused = true)] + async fn the_runner_answers_a_host_and_ends_with_its_channel() { + let wire = Arc::new(Wire::new()); + let h_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); + let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); + let h_central = Arc::new(FakeCentral::new(wire)); + let h_serving = Arc::new(Mutex::new(Some(fp(1)))); + let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (bt_machine, _bt_beacon) = Machine::new( + h_readiness, + h_peripheral, + Some(h_central as Arc), + quiet_node(), + false, + false, + true, + Arc::new({ + let serving = Arc::clone(&h_serving); + move || *serving.lock() + }), + Arc::new(move |_offer, _reply| {}), + Arc::new({ + let events = Arc::clone(&h_events); + move |event| events.lock().push(event) + }), + ); + let hotspot_backend: Arc = Arc::new(FakeHotspotBackend::new()); + let hotspot_events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let hotspot_machine = crate::hotspot::Machine::new( + Arc::clone(&hotspot_backend) as Arc, + false, + Arc::new(|| Some(54321)), + Arc::new(|_subnet| {}), + Arc::new(|_addr, _answer| {}), + Arc::new({ + let events = Arc::clone(&hotspot_events); + move |event| events.lock().push(event) + }), + ); + let (hotspot_tx, hotspot_rx) = mpsc::unbounded_channel(); + let (bt_tx, bt_rx) = mpsc::unbounded_channel(); + let runner = run(hotspot_machine, hotspot_rx, bt_machine, bt_rx); + let runner = tokio::spawn(async move { + tokio::pin!(runner); + runner.await; + }); + let (host_tx, host_rx) = oneshot::channel(); + bt_tx + .send(Command::Host(host_tx)) + .expect("the channel is open"); + let hosted = tokio::time::timeout(Duration::from_secs(5), host_rx) + .await + .expect("the host's answer arrives") + .expect("the channel stays open"); + let hosted = hosted.expect("the hotspot starts"); + assert_eq!(hosted.offer.ssid, "hotspot"); + assert!(hosted.beacon.is_ok()); + // Both channels close, so the runner ends with them. + drop(hotspot_tx); + drop(bt_tx); + tokio::time::timeout(Duration::from_secs(5), runner) + .await + .expect("the runner ends") + .expect("the runner task is sound"); + } +} diff --git a/crates/connetto-client/src/bluetooth/android.rs b/crates/connetto-client/src/bluetooth/android.rs new file mode 100644 index 00000000..a3f89ac1 --- /dev/null +++ b/crates/connetto-client/src/bluetooth/android.rs @@ -0,0 +1,229 @@ +//! The Android platform's Bluetooth, over the bundled plugin (R76). + +use std::sync::Arc; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as CHUNKS; +use jni::objects::{JObject, JValue}; + +use super::{ + BluetoothError, BluetoothState, PeripheralBackend, PeripheralEvent, PromptOutcome, + ReadinessBackend, +}; +use crate::device_key::JavaAccess; +use crate::hotspot::android::{call, int, plugin, string}; + +/// The bundled plugin's class. +const PLUGIN_CLASS: &str = "dev.connetto.peer.BluetoothPlugin"; +/// The Android platform's Bluetooth, over the application's JNI access +/// (R76). +pub(crate) struct AndroidBluetoothBackend { + java: Arc, +} + +impl AndroidBluetoothBackend { + /// A backend reaching the application's plugin through `java`. + pub(crate) fn new(java: Arc) -> Self { + Self { java } + } +} + +impl ReadinessBackend for AndroidBluetoothBackend { + fn state(&self) -> BluetoothState { + match call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + Ok(match int(env, &class, "state")? { + 1 => BluetoothState::Ready, + 2 => BluetoothState::Off, + 3 => BluetoothState::NotPermitted, + _ => BluetoothState::Unsupported, + }) + }) { + Ok(state) => state, + Err(failure) => { + tracing::warn!(%failure, "the Bluetooth standing could not be read"); + BluetoothState::Unsupported + } + } + } + + fn missing_permissions(&self) -> Vec { + match call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + let Some(text) = string(env, &class, "missingPermissions")? else { + return Ok(Vec::new()); + }; + Ok(text.lines().map(str::to_string).collect()) + }) { + Ok(missing) => missing, + Err(failure) => { + tracing::warn!(%failure, "the Bluetooth permissions could not be read"); + Vec::new() + } + } + } + + fn prompt(&self) -> Result<(), BluetoothError> { + let started = call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + int(env, &class, "prompt") + }) + .map_err(|failure| BluetoothError::Failed(failure.to_string()))?; + match started { + 0 => Ok(()), + // The action is blocked, so the call's rows stand on the + // standing's own reason. + _ => Err(BluetoothError::Failed( + "the prompt action is blocked".into(), + )), + } + } + + fn prompt_outcome(&self) -> Option { + call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + Ok(match int(env, &class, "promptOutcome")? { + 0 => Some(PromptOutcome::NotAsked), + 1 => Some(PromptOutcome::Declined), + 2 => Some(PromptOutcome::SentToSettings), + 3 => Some(PromptOutcome::Blocked), + // The action is in flight, or the platform names a result + // the client does not. + _ => None, + }) + }) + .unwrap_or(None) + } +} + +impl PeripheralBackend for AndroidBluetoothBackend { + fn start(&self, beacon: [u8; 9]) -> Result<(), BluetoothError> { + let started = call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + let bytes = env.byte_array_from_slice(&beacon)?; + env.call_static_method( + &class, + "startAdvertising", + "([B)I", + &[JValue::Object(&JObject::from(bytes))], + )? + .i() + }) + .map_err(|failure| BluetoothError::Failed(failure.to_string()))?; + match started { + 0 => Ok(()), + code => Err(BluetoothError::Failed(format!( + "the advertisement failed with {code}" + ))), + } + } + + fn stop(&self) { + if let Err(failure) = call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + env.call_static_method(&class, "stopAdvertising", "()V", &[]) + .map(drop) + }) { + tracing::warn!(%failure, "the advertisement could not be stopped"); + } + } + + fn poll(&self) -> Vec { + let events = call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + let Some(text) = string(env, &class, "poll")? else { + return Ok(Vec::::new()); + }; + Ok(text + .lines() + .filter_map(|line| { + let mut parts = line.split('\t'); + match parts.next()? { + // A joiner connected, with the negotiated packet + // size. + "1" => { + let device = parts.next()?.parse().ok()?; + let mtu = parts.next()?.parse().ok()?; + Some(PeripheralEvent::Connected { device, mtu }) + } + // A joiner disconnected. + "2" => { + let device = parts.next()?.parse().ok()?; + Some(PeripheralEvent::Disconnected { device }) + } + // The advertisement failed, with the platform's + // code. + "3" => Some(PeripheralEvent::Failed(format!( + "the advertisement failed with {}", + parts.next().unwrap_or("the platform's code") + ))), + // A chunk the joiner wrote. + "4" => { + let device = parts.next()?.parse().ok()?; + let Ok(bytes) = CHUNKS.decode(parts.next()?) else { + return None; + }; + Some(PeripheralEvent::Chunk { device, bytes }) + } + _ => None, + } + }) + .collect()) + }) + .unwrap_or_default(); + if events.len() > 1 { + tracing::debug!( + count = events.len(), + "the Bluetooth service reported its events" + ); + } + events + } + + fn notify(&self, device: u64, bytes: &[u8]) -> Result<(), BluetoothError> { + // The device key is the platform's 48-bit MAC fold, so it stands in + // a Long. + let Ok(device) = i64::try_from(device) else { + return Err(BluetoothError::Failed( + "the device key exceeds the platform's Long".into(), + )); + }; + let sent = call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + let chunk = env.byte_array_from_slice(bytes)?; + env.call_static_method( + &class, + "notify", + "(J[B)I", + &[JValue::Long(device), JValue::Object(&JObject::from(chunk))], + )? + .i() + }) + .map_err(|failure| BluetoothError::Failed(failure.to_string()))?; + match sent { + 0 => Ok(()), + _ => Err(BluetoothError::Failed( + "the notification will not send".into(), + )), + } + } + + fn disconnect(&self, device: u64) { + let Ok(device) = i64::try_from(device) else { + tracing::warn!(device, "the device key exceeds the platform's Long"); + return; + }; + if let Err(failure) = call(&*self.java, |env| { + let class = plugin(env, PLUGIN_CLASS)?; + env.call_static_method( + &class, + "disconnectPeripheral", + "(J)V", + &[JValue::Long(device)], + ) + .map(drop) + }) { + tracing::warn!(%failure, "the joiner could not be disconnected"); + } + } +} diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index 8e6fbcf0..5506f103 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -302,6 +302,10 @@ where teardown: None, #[cfg(feature = "device-identity")] device: None, + #[cfg(feature = "peer")] + beacon: None, + #[cfg(feature = "peer")] + bt_state: None, }, pump, )) @@ -446,6 +450,13 @@ pub struct NativeDurable { /// What a keyring build enrols its device key with (R74). #[cfg(feature = "device-identity")] +#[cfg_attr( + feature = "peer", + expect( + clippy::struct_excessive_bools, + reason = "autolink, scan, autojoin and prompt are independent switches the application sets one by one" + ) +)] struct DeviceSetup { lifetime: Option, descriptor: Vec, @@ -457,6 +468,12 @@ struct DeviceSetup { peer_listen: SocketAddr, #[cfg(feature = "peer")] peer_accepted: Vec, + #[cfg(feature = "peer")] + bt_scan: bool, + #[cfg(feature = "peer")] + bt_autojoin: bool, + #[cfg(feature = "peer")] + bt_prompt: bool, #[cfg(target_os = "android")] java: Option>, } @@ -476,6 +493,12 @@ impl Default for DeviceSetup { peer_listen: SocketAddr::from(([0, 0, 0, 0], 0)), #[cfg(feature = "peer")] peer_accepted: AttestationLevel::ALL.to_vec(), + #[cfg(feature = "peer")] + bt_scan: true, + #[cfg(feature = "peer")] + bt_autojoin: false, + #[cfg(feature = "peer")] + bt_prompt: true, #[cfg(target_os = "android")] java: None, } @@ -605,6 +628,36 @@ where self } + /// Whether the device scans for nearby hosts in the background, on by + /// default (R76 decision 19). + #[cfg(feature = "peer")] + #[must_use] + pub fn with_hotspot_scan(mut self, scan: bool) -> Self { + self.device.bt_scan = scan; + self + } + + /// Whether the device joins the strongest host it sees, off by default + /// (R76 decision 19). Implies the scan. + #[cfg(feature = "peer")] + #[must_use] + pub fn with_hotspot_autojoin(mut self, autojoin: bool) -> Self { + self.device.bt_autojoin = autojoin; + if autojoin { + self.device.bt_scan = true; + } + self + } + + /// Whether the call-driven Bluetooth prompts run, on by default (R76 + /// decision 21). + #[cfg(feature = "peer")] + #[must_use] + pub fn with_bluetooth_prompt(mut self, prompt: bool) -> Self { + self.device.bt_prompt = prompt; + self + } + /// The JNI access the Android Keystore key needs, which an Android build /// with a device identity must hand in (decision 20). /// `connetto_auth_session::java_access()` is one for a Dioxus application. @@ -734,6 +787,12 @@ where #[cfg(not(feature = "native-auth"))] let _ = (resolved, key_store); let (core, pump) = core.connect_with_pump().await?; + #[cfg(feature = "peer")] + let mut beacon: Option< + tokio::sync::watch::Receiver, + > = None; + #[cfg(feature = "peer")] + let mut bt_state: Option> = None; #[cfg(feature = "device-identity")] let (device, pump) = match keys { Some(keys) => { @@ -746,6 +805,8 @@ where #[cfg(feature = "peer")] let (hotspot_tx, hotspot_rx) = mpsc::unbounded_channel(); #[cfg(feature = "peer")] + let (bluetooth_tx, bluetooth_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] let node = connetto_peer::Node::new( connetto_peer::Trust { roots: device.roots.clone(), @@ -787,10 +848,12 @@ where peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let enrolment = core.client().enrolment(enroller); #[cfg(feature = "peer")] - let hotspot_runner = { + let (runner, beacon_rx, bt_backend) = { let backend: Arc = { #[cfg(target_os = "android")] { @@ -811,6 +874,7 @@ where let bind_node = peer_node.clone(); let dial_handle = handle.clone(); let event_sender = core.client().event_sender(); + let event_sender_bt = event_sender.clone(); #[cfg(target_os = "android")] let bind = { let java = device.java.clone(); @@ -858,16 +922,71 @@ where let _ = event_sender.send(event); }), ); - crate::hotspot::run(machine, hotspot_rx) + #[cfg(target_os = "android")] + let (bt_readiness, bt_peripheral, bt_central) = match device.java.clone() { + Some(java) => { + let backend = + Arc::new(crate::bluetooth::AndroidBluetoothBackend::new(java)); + ( + Arc::clone(&backend) as Arc, + Arc::clone(&backend) + as Arc, + // The Android central is btleplug's, which a later slice + // brings (R76 decision 20). + None, + ) + } + None => crate::bluetooth::unsupported_backends(), + }; + #[cfg(not(target_os = "android"))] + let (bt_readiness, bt_peripheral, bt_central) = + crate::bluetooth::unsupported_backends(); + let bt_state = Arc::clone(&bt_readiness); + let serving_handle = handle.clone(); + let join_handle = handle.clone(); + let beacon_node = peer_node.clone(); + let bt_machine = crate::bluetooth::Machine::new( + bt_readiness, + bt_peripheral, + bt_central, + beacon_node, + device.bt_scan, + device.bt_autojoin, + device.bt_prompt, + Arc::new({ + let handle = serving_handle; + move || handle.peer_serving_fingerprint() + }), + Arc::new(move |offer, reply| { + let handle = join_handle.clone(); + tokio::spawn(async move { + let answer = handle.join_hotspot(&offer).await; + let _ = reply.send(answer); + }); + }), + Arc::new(move |event: ClientEvent| { + let _ = event_sender_bt.send(event); + }), + ); + ( + crate::bluetooth::run(machine, hotspot_rx, bt_machine.0, bluetooth_rx), + bt_machine.1, + bt_state, + ) }; #[cfg(feature = "peer")] let pump: CorePump = Box::pin(async move { - tokio::join!(pump, enrolment, hotspot_runner); + tokio::join!(pump, enrolment, runner); }); #[cfg(not(feature = "peer"))] let pump: CorePump = Box::pin(async move { tokio::join!(pump, enrolment); }); + #[cfg(feature = "peer")] + { + beacon = Some(beacon_rx); + bt_state = Some(bt_backend); + } (Some(handle), pump) } None => (None, pump), @@ -881,6 +1000,10 @@ where teardown, #[cfg(feature = "device-identity")] device, + #[cfg(feature = "peer")] + beacon, + #[cfg(feature = "peer")] + bt_state, }, pump, )) @@ -939,6 +1062,12 @@ pub struct NativeClient { teardown: Option>, #[cfg(feature = "device-identity")] device: Option, + #[cfg(feature = "peer")] + /// The host's beacon standing, for the application's panel (R76). + beacon: Option>, + #[cfg(feature = "peer")] + /// The platform's Bluetooth standing, readable at any time (R76). + bt_state: Option>, } impl NativeClient @@ -1078,8 +1207,8 @@ where } } - /// Host this device's hotspot, answering its details or the reason it - /// will not, within the machine's bound (R76). + /// Host this device's hotspot, the beacon's outcome beside its details + /// or the reason it will not, within the machines' bounds (R76). /// /// # Errors /// @@ -1087,9 +1216,10 @@ where /// build without a device identity, [`HotspotError::MissingPermission`] /// with a permission the device has not granted, the mapped failure from /// the device's Wi-Fi manager, and [`HotspotError::TimedOut`] when the - /// hotspot does not start within its bound. + /// hotspot does not start within its bound. The beacon's refusal stands + /// beside the offer on the success. #[cfg(feature = "peer")] - pub async fn host_hotspot(&self) -> Result { + pub async fn host_hotspot(&self) -> Result { match &self.device { Some(device) => device.host_hotspot().await, None => Err(HotspotError::Unsupported), @@ -1146,6 +1276,115 @@ where device.leave_hotspot(); } } + + /// The platform's Bluetooth standing, readable at any time (R76 + /// decision 21). + #[cfg(feature = "peer")] + #[must_use] + pub fn bluetooth_state(&self) -> crate::bluetooth::BluetoothState { + match &self.bt_state { + Some(backend) => backend.state(), + None => crate::bluetooth::BluetoothState::Unsupported, + } + } + + /// The host's beacon standing, for the application's panel (R76). + #[cfg(feature = "peer")] + #[must_use] + pub fn beacon_state(&self) -> crate::bluetooth::BeaconState { + match &self.beacon { + Some(receiver) => receiver.borrow().clone(), + None => crate::bluetooth::BeaconState::Off { + reason: "no bluetooth on this system", + }, + } + } + + /// Enable Bluetooth, the platform's action inside the call, within the + /// machine's bound (R76 decision 21). + /// + /// # Errors + /// + /// [`BluetoothError::Unsupported`](crate::BluetoothError::Unsupported) for a build without a device + /// identity, [`BluetoothError::Off`](crate::BluetoothError::Off) or [`BluetoothError::NotPermitted`](crate::BluetoothError::NotPermitted) + /// with the prompt's outcome once the action declines, and + /// [`BluetoothError::TimedOut`](crate::BluetoothError::TimedOut) when the action does not finish within + /// its bound. + #[cfg(feature = "peer")] + pub async fn enable_bluetooth(&self) -> Result<(), crate::bluetooth::BluetoothError> { + match &self.device { + Some(device) => device.enable_bluetooth().await, + None => Err(crate::bluetooth::BluetoothError::Unsupported), + } + } + + /// Join the nearby host's hotspot through the exchange, within the + /// machines' bounds (R76 decision 19). + /// + /// # Errors + /// + /// [`JoinNearbyError::Bluetooth`](crate::JoinNearbyError::Bluetooth) with the Bluetooth reason the exchange + /// or the platform's action gives, and [`JoinNearbyError::Join`](crate::JoinNearbyError::Join) with the + /// hotspot's reason the network gives. + #[cfg(feature = "peer")] + pub async fn join_nearby( + &self, + host: &crate::bluetooth::HostId, + ) -> Result { + match &self.device { + Some(device) => device.join_nearby(host).await, + None => Err(crate::bluetooth::JoinNearbyError::Bluetooth( + crate::bluetooth::BluetoothError::Unsupported, + )), + } + } + + /// Fetch the nearby host's offer through the exchange, within the + /// machine's bound (R76 decision 19). + /// + /// # Errors + /// + /// [`BluetoothError::Busy`](crate::BluetoothError::Busy) while an exchange runs, + /// [`BluetoothError::Exchange`](crate::BluetoothError::Exchange) with the link's reason the host's chain + /// refuses or the exchange stalls, and + /// [`BluetoothError::TimedOut`](crate::BluetoothError::TimedOut) at the bound. + #[cfg(feature = "peer")] + pub async fn fetch_offer( + &self, + host: &crate::bluetooth::HostId, + ) -> Result { + match &self.device { + Some(device) => device.fetch_offer(host).await, + None => Err(crate::bluetooth::BluetoothError::Unsupported), + } + } + + /// The scan's standing, at run time (R76 decision 19). A no-op for a + /// build without a device identity. + #[cfg(feature = "peer")] + pub fn set_hotspot_scan(&self, scan: bool) { + if let Some(device) = &self.device { + device.set_hotspot_scan(scan); + } + } + + /// The autojoin's standing, at run time, implying the scan (R76 decision + /// 19). A no-op for a build without a device identity. + #[cfg(feature = "peer")] + pub fn set_hotspot_autojoin(&self, autojoin: bool) { + if let Some(device) = &self.device { + device.set_hotspot_autojoin(autojoin); + } + } + + /// The prompt's standing, at run time (R76 decision 21). A no-op for a + /// build without a device identity. + #[cfg(feature = "peer")] + pub fn set_bluetooth_prompt(&self, prompt: bool) { + if let Some(device) = &self.device { + device.set_bluetooth_prompt(prompt); + } + } } #[cfg(feature = "native-auth")] @@ -1350,6 +1589,10 @@ impl NativeClient { teardown: None, #[cfg(feature = "device-identity")] device: None, + #[cfg(feature = "peer")] + beacon: None, + #[cfg(feature = "peer")] + bt_state: None, } } } diff --git a/crates/connetto-client/src/enrolment/task.rs b/crates/connetto-client/src/enrolment/task.rs index 9af9d316..b1e0e6a1 100644 --- a/crates/connetto-client/src/enrolment/task.rs +++ b/crates/connetto-client/src/enrolment/task.rs @@ -23,13 +23,15 @@ use connetto_core::messages::{ EnrolRequest, FatalErrorReason, RevokeDeviceRequest, SignedList, SyncStatus, }; #[cfg(feature = "peer")] -use connetto_peer::{CloseReason, DiscoveryEvent, LinkError, PeerEvent}; +use connetto_peer::{CloseReason, DiscoveryEvent, EXCHANGE_BOUND, LinkError, PeerEvent}; use tokio::sync::broadcast::error::RecvError; use tokio::sync::{broadcast, mpsc, oneshot, watch}; use super::{Answer, Held, KeptList, Standing, TOLERANCE, half_life}; #[cfg(feature = "peer")] use crate::PeerError; +#[cfg(feature = "peer")] +use crate::bluetooth::{BluetoothError, HostId, JoinNearbyError, PROMPT_BOUND}; use crate::device_key::{ChipError, ChipKeys, KeyRecords, OpenedKey}; #[cfg(feature = "peer")] use crate::hotspot::{HOST_BOUND, HotspotError, HotspotOffer, JOIN_BOUND, JoinError, MARGIN}; @@ -276,6 +278,10 @@ pub(crate) struct Enroller { /// task does (R76). clock_off: Arc, #[cfg(feature = "peer")] + /// The fingerprint the device serves, its standing the peer node + /// reports (R76). + peer_serving: watch::Sender>, + #[cfg(feature = "peer")] peer: Option, } @@ -295,6 +301,12 @@ pub(crate) struct EnrolHandle { #[cfg(feature = "peer")] /// The hotspot machine's commands, until it ends with the client (R76). hotspot: mpsc::UnboundedSender, + #[cfg(feature = "peer")] + /// The Bluetooth machine's commands, until it ends with the client (R76). + bluetooth: mpsc::UnboundedSender, + #[cfg(feature = "peer")] + /// The fingerprint the device serves, once it serves (R76). + peer_serving: watch::Receiver>, } impl Enroller { @@ -307,7 +319,7 @@ impl Enroller { feature = "peer", expect( clippy::too_many_arguments, - reason = "the peer node and its hotspot channel join the seven enrolment inputs and a config struct would hide the same arity behind another type" + reason = "the peer node and its hotspot and Bluetooth channels join the seven enrolment inputs and a config struct would hide the same arity behind another type" ) )] pub(crate) fn new( @@ -320,6 +332,7 @@ impl Enroller { lists: super::ListInbox, #[cfg(feature = "peer")] peer: Peer, #[cfg(feature = "peer")] hotspot: mpsc::UnboundedSender, + #[cfg(feature = "peer")] bluetooth: mpsc::UnboundedSender, ) -> (Self, EnrolHandle) { let (sender, commands) = mpsc::unbounded_channel(); let (published, observed) = watch::channel(held.as_ref().map(|held| held.leaf.clone())); @@ -327,6 +340,8 @@ impl Enroller { let clock_off = Arc::new(AtomicBool::new(false)); #[cfg(feature = "peer")] let peer_node = peer.node.clone(); + #[cfg(feature = "peer")] + let (peer_serving, peer_serving_rx) = watch::channel(None); ( Self { keys: Arc::clone(&keys), @@ -341,6 +356,8 @@ impl Enroller { home, clock_off: Arc::clone(&clock_off), #[cfg(feature = "peer")] + peer_serving, + #[cfg(feature = "peer")] peer: Some(peer), }, EnrolHandle { @@ -354,6 +371,10 @@ impl Enroller { peer: peer_node, #[cfg(feature = "peer")] hotspot, + #[cfg(feature = "peer")] + bluetooth, + #[cfg(feature = "peer")] + peer_serving: peer_serving_rx, }, ) } @@ -436,16 +457,16 @@ impl EnrolHandle { } } - /// Host this device's hotspot, answered with its details or the reason - /// it will not, within the machine's bound (R76). + /// Host this device's hotspot, its beacon's outcome beside the offer, + /// within the machines' bounds (R76). #[cfg(feature = "peer")] - pub(crate) async fn host_hotspot(&self) -> Result { + pub(crate) async fn host_hotspot(&self) -> Result { let (reply, answer) = oneshot::channel(); - self.hotspot - .send(crate::hotspot::Command::Host(reply)) + self.bluetooth + .send(crate::bluetooth::Command::Host(reply)) .map_err(|_| HotspotError::Failed)?; - match tokio::time::timeout(HOST_BOUND + MARGIN, answer).await { - Ok(Ok(offer)) => offer, + match tokio::time::timeout(HOST_BOUND + PROMPT_BOUND + MARGIN, answer).await { + Ok(Ok(hosted)) => hosted, Ok(Err(_)) => Err(HotspotError::Failed), Err(_) => Err(HotspotError::TimedOut), } @@ -483,6 +504,91 @@ impl EnrolHandle { pub(crate) fn leave_hotspot(&self) { let _ = self.hotspot.send(crate::hotspot::Command::Leave); } + + /// Enable Bluetooth, the platform's action inside the call, within the + /// machine's bound (R76 decision 21). + #[cfg(feature = "peer")] + pub(crate) async fn enable_bluetooth(&self) -> Result<(), BluetoothError> { + let (reply, answer) = oneshot::channel(); + self.bluetooth + .send(crate::bluetooth::Command::Enable(reply)) + .map_err(|_| BluetoothError::Failed("the client is gone".into()))?; + match tokio::time::timeout(PROMPT_BOUND + MARGIN, answer).await { + Ok(Ok(answer)) => answer, + Ok(Err(_)) => Err(BluetoothError::Failed("the client is gone".into())), + Err(_) => Err(BluetoothError::TimedOut), + } + } + + /// Join the nearby host's hotspot through the exchange, within the + /// machines' bounds (R76 decision 19). + #[cfg(feature = "peer")] + pub(crate) async fn join_nearby( + &self, + host: &HostId, + ) -> Result { + let (reply, answer) = oneshot::channel(); + self.bluetooth + .send(crate::bluetooth::Command::JoinNearby { host: *host, reply }) + .map_err(|_| JoinNearbyError::Join(JoinError::Failed))?; + // A Bluetooth not yet ready runs its action first, within its own + // bound, before the exchange and the join. + match tokio::time::timeout(PROMPT_BOUND + EXCHANGE_BOUND + JOIN_BOUND + MARGIN, answer) + .await + { + Ok(Ok(answer)) => answer, + Ok(Err(_)) => Err(JoinNearbyError::Join(JoinError::Failed)), + Err(_) => Err(JoinNearbyError::Join(JoinError::TimedOut)), + } + } + + /// Fetch the nearby host's offer through the exchange, within the + /// machine's bound (R76 decision 19). + #[cfg(feature = "peer")] + pub(crate) async fn fetch_offer(&self, host: &HostId) -> Result { + let (reply, answer) = oneshot::channel(); + self.bluetooth + .send(crate::bluetooth::Command::Fetch { host: *host, reply }) + .map_err(|_| BluetoothError::Failed("the client is gone".into()))?; + // A Bluetooth not yet ready runs its action first, within its own + // bound, before the exchange. + match tokio::time::timeout(PROMPT_BOUND + EXCHANGE_BOUND + MARGIN, answer).await { + Ok(Ok(answer)) => answer, + Ok(Err(_)) => Err(BluetoothError::Failed("the client is gone".into())), + Err(_) => Err(BluetoothError::TimedOut), + } + } + + /// The scan's standing, at run time (R76 decision 19). + #[cfg(feature = "peer")] + pub(crate) fn set_hotspot_scan(&self, scan: bool) { + let _ = self + .bluetooth + .send(crate::bluetooth::Command::SetScan(scan)); + } + + /// The autojoin's standing, at run time, implying the scan (R76 decision + /// 19). + #[cfg(feature = "peer")] + pub(crate) fn set_hotspot_autojoin(&self, autojoin: bool) { + let _ = self + .bluetooth + .send(crate::bluetooth::Command::SetAutojoin(autojoin)); + } + + /// The prompt's standing, at run time (R76 decision 21). + #[cfg(feature = "peer")] + pub(crate) fn set_bluetooth_prompt(&self, prompt: bool) { + let _ = self + .bluetooth + .send(crate::bluetooth::Command::SetPrompt(prompt)); + } + + /// The fingerprint the device serves, once it serves (R76). + #[cfg(feature = "peer")] + pub(crate) fn peer_serving_fingerprint(&self) -> Option { + *self.peer_serving.borrow() + } } /// How long to sleep before looking at `held` again. @@ -1005,6 +1111,7 @@ impl Run { Ok(key) => key, Err(err) => { tracing::warn!(error = %err, "the device key could not be opened, so the peer link waits"); + self.enroller.peer_serving.send_replace(None); return; } }; @@ -1026,10 +1133,12 @@ impl Run { ), } } + self.enroller.peer_serving.send_replace(Some(fingerprint)); self.discovery.serve(bound.port(), fingerprint); } Err(err) => { tracing::warn!(error = %err, "the peer listener could not bind"); + self.enroller.peer_serving.send_replace(None); self.link.emit(ClientEvent::PeerListenFailed { address: self.peer_listen, error: err.to_string(), @@ -1043,6 +1152,7 @@ impl Run { #[cfg(feature = "peer")] fn peer_stop(&mut self, reason: CloseReason) { self.peer_standing = Standing::NoKey; + self.enroller.peer_serving.send_replace(None); #[cfg(all(feature = "peer", target_os = "android"))] { self.multicast = None; diff --git a/crates/connetto-client/src/enrolment/tests.rs b/crates/connetto-client/src/enrolment/tests.rs index 1378679d..4762f031 100644 --- a/crates/connetto-client/src/enrolment/tests.rs +++ b/crates/connetto-client/src/enrolment/tests.rs @@ -667,6 +667,8 @@ async fn a_refused_attestation_is_raised_once_and_asks_again_only_on_the_next_co }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, _handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -679,6 +681,8 @@ async fn a_refused_attestation_is_raised_once_and_asks_again_only_on_the_next_co peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -754,6 +758,8 @@ async fn a_renewal_sends_no_attestation() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, _handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -766,6 +772,8 @@ async fn a_renewal_sends_no_attestation() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -811,6 +819,8 @@ async fn a_reissue_refused_as_revoked_deletes_the_key_and_reports_revoked() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -823,6 +833,8 @@ async fn a_reissue_refused_as_revoked_deletes_the_key_and_reports_revoked() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -871,6 +883,8 @@ async fn a_reissue_refused_over_the_ceiling_reports_the_ceiling() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -883,6 +897,8 @@ async fn a_reissue_refused_over_the_ceiling_reports_the_ceiling() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -924,6 +940,8 @@ async fn a_reissue_refused_for_another_reason_reports_that_reason() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -936,6 +954,8 @@ async fn a_reissue_refused_for_another_reason_reports_that_reason() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -976,6 +996,8 @@ async fn a_reissue_with_no_answer_is_offline() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -988,6 +1010,8 @@ async fn a_reissue_with_no_answer_is_offline() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1030,6 +1054,8 @@ async fn a_reissue_lost_on_the_wire_is_offline() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1042,6 +1068,8 @@ async fn a_reissue_lost_on_the_wire_is_offline() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1082,6 +1110,8 @@ async fn a_reissue_violating_the_protocol_is_a_device_error() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1094,6 +1124,8 @@ async fn a_reissue_violating_the_protocol_is_a_device_error() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1346,6 +1378,8 @@ async fn a_fresh_device_serves_its_peer_listener_at_open() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1357,6 +1391,8 @@ async fn a_fresh_device_serves_its_peer_listener_at_open() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1393,6 +1429,8 @@ async fn an_expired_device_refuses_to_link_and_raises_the_event() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1404,6 +1442,8 @@ async fn an_expired_device_refuses_to_link_and_raises_the_event() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1458,6 +1498,8 @@ async fn a_clock_off_device_refuses_to_link_outside_its_window() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1469,6 +1511,8 @@ async fn a_clock_off_device_refuses_to_link_outside_its_window() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1536,6 +1580,8 @@ async fn a_grant_past_its_window_refuses_the_dial_as_a_window() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1547,6 +1593,8 @@ async fn a_grant_past_its_window_refuses_the_dial_as_a_window() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1608,6 +1656,8 @@ async fn a_certificate_less_device_refuses_to_link() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1619,6 +1669,8 @@ async fn a_certificate_less_device_refuses_to_link() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1676,6 +1728,8 @@ async fn a_look_at_the_expiry_closes_the_live_peer_links() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1687,6 +1741,8 @@ async fn a_look_at_the_expiry_closes_the_live_peer_links() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1775,6 +1831,8 @@ async fn a_granted_renewal_keeps_the_peer_port_and_the_live_link() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1786,6 +1844,8 @@ async fn a_granted_renewal_keeps_the_peer_port_and_the_live_link() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -1882,6 +1942,8 @@ async fn a_peer_list_revoking_the_own_serial_revokes_the_device() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -1893,6 +1955,8 @@ async fn a_peer_list_revoking_the_own_serial_revokes_the_device() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -2013,6 +2077,8 @@ async fn a_stale_peer_list_changes_nothing() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller, handle) = Enroller::new( Arc::new(RunKeys { der }), Some(HOUR), @@ -2024,6 +2090,8 @@ async fn a_stale_peer_list_changes_nothing() { peer, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run = tokio::spawn(run(link, enroller)); @@ -2133,6 +2201,8 @@ async fn two_clients_discover_each_other_and_autolink() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller_a, _handle_a) = Enroller::new( Arc::new(RunKeys { der: der_a }), Some(HOUR), @@ -2144,9 +2214,13 @@ async fn two_clients_discover_each_other_and_autolink() { peer_a, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller_b, _handle_b) = Enroller::new( Arc::new(RunKeys { der: der_b }), Some(HOUR), @@ -2158,6 +2232,8 @@ async fn two_clients_discover_each_other_and_autolink() { peer_b, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run_a = tokio::spawn(run(link_a, enroller_a)); let run_b = tokio::spawn(run(link_b, enroller_b)); @@ -2227,6 +2303,8 @@ async fn clients_without_autolink_report_and_link_only_on_the_call() { }; #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller_a, handle_a) = Enroller::new( Arc::new(RunKeys { der: der_a }), Some(HOUR), @@ -2238,9 +2316,13 @@ async fn clients_without_autolink_report_and_link_only_on_the_call() { peer_a, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); #[cfg(feature = "peer")] let (hotspot_tx, _hotspot_rx) = mpsc::unbounded_channel(); + #[cfg(feature = "peer")] + let (bluetooth_tx, _bluetooth_rx) = mpsc::unbounded_channel(); let (enroller_b, _handle_b) = Enroller::new( Arc::new(RunKeys { der: der_b }), Some(HOUR), @@ -2252,6 +2334,8 @@ async fn clients_without_autolink_report_and_link_only_on_the_call() { peer_b, #[cfg(feature = "peer")] hotspot_tx, + #[cfg(feature = "peer")] + bluetooth_tx, ); let run_a = tokio::spawn(run(link_a, enroller_a)); let run_b = tokio::spawn(run(link_b, enroller_b)); diff --git a/crates/connetto-client/src/hotspot.rs b/crates/connetto-client/src/hotspot.rs index 568e3b32..0cef1db6 100644 --- a/crates/connetto-client/src/hotspot.rs +++ b/crates/connetto-client/src/hotspot.rs @@ -8,21 +8,19 @@ //! backend reaches the bundled plugin through the application's JNI access. use std::fmt; -use std::future::Future; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::pin::Pin; use std::sync::Arc; use std::time::Duration; #[cfg(test)] use parking_lot::Mutex; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::oneshot; use zeroize::Zeroize; use crate::ClientEvent; #[cfg(target_os = "android")] -mod android; +pub(crate) mod android; #[cfg(target_os = "android")] pub(crate) use android::{AndroidHotspotBackend, JoinedBind}; @@ -38,7 +36,7 @@ pub(crate) const DIAL_BOUND: Duration = Duration::from_secs(60); /// The wait between a failed gateway dial and its redial. pub(crate) const REDIAL: Duration = Duration::from_secs(2); /// The pace of the machine's look at the backend's outcome. -const TICK: Duration = Duration::from_millis(250); +pub(crate) const TICK: Duration = Duration::from_millis(250); /// The security a hotspot's Wi-Fi carries. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -318,7 +316,7 @@ type SubnetBind = Arc) + Send + Sync>; /// The hotspot machine, one per client (R76). pub(crate) struct Machine { - backend: Arc, + pub(crate) backend: Arc, autolink: bool, /// The peer port the device serves, from its listener's standing. peer_port: Arc Option + Send + Sync>, @@ -371,6 +369,19 @@ impl Machine { } } + /// The offer the host side holds, while it hosts. + pub(crate) fn hosting_offer(&self) -> Option { + match &self.host { + HostState::Hosting { offer } => Some(offer.clone()), + _ => None, + } + } + + /// Whether the join side stands Joined. + pub(crate) fn joined(&self) -> bool { + matches!(self.join, JoinState::Joined { .. }) + } + /// Take `command` and steer the tables to their rows. pub(crate) fn handle(&mut self, command: Command) { match command { @@ -628,66 +639,7 @@ impl Machine { self.host = HostState::Off; self.join = JoinState::Idle; } - - /// The loop's end, its channel closed. - async fn run(mut self, mut commands: mpsc::UnboundedReceiver) { - loop { - let (command, tick) = tokio::select! { - command = commands.recv() => (command, false), - () = tokio::time::sleep(TICK) => (None, true), - }; - let closed = command.is_none() && !tick; - if let Some(command) = command { - self.handle(command); - } - if tick { - self.tick(); - } - // A closed channel reports `None` where a tick does too. - if closed { - self.close(); - break; - } - } - } -} - -/// The machine's loop beside the client's pump, ending with it (R76). -pub(crate) struct Runner { - future: Pin + Send>>, - backend: Arc, -} - -impl Future for Runner { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<()> { - self.future.as_mut().poll(cx) - } } - -impl Drop for Runner { - fn drop(&mut self) { - // A dropped client leaves the machine unpollable, so the tables' - // "Client closed" rows apply here, where they are no-ops on the - // backend's own standing. - self.backend.stop_host(); - self.backend.leave_join(); - } -} - -/// The machine beside the client's pump, the backend's stop and leave -/// applying to a dropped client (R76). -pub(crate) fn run(machine: Machine, commands: mpsc::UnboundedReceiver) -> Runner { - let backend = Arc::clone(&machine.backend); - Runner { - future: Box::pin(async move { - machine.run(commands).await; - }), - backend, - } -} - #[cfg(test)] mod tests { use super::*; @@ -1431,58 +1383,6 @@ mod tests { ); } - /// The runner answers a command through its channel and ends with it. - #[tokio::test] - async fn the_runner_answers_a_host_and_ends_with_its_channel() { - tokio::time::pause(); - let backend: Arc = Arc::new(FakeBackend::new()); - let machine = Machine::new( - Arc::clone(&backend) as Arc, - true, - Arc::new(|| Some(54321)), - Arc::new(|_| {}), - Arc::new(|_, _| {}), - Arc::new(|_| {}), - ); - let (tx, rx) = mpsc::unbounded_channel(); - let runner = run(machine, rx); - let joined = tokio::spawn(async move { - runner.await; - }); - let (reply, mut answer) = oneshot::channel(); - tx.send(Command::Host(reply)).unwrap(); - // The loop's ticks, with the started outcome between them. - for _ in 0..8 { - tokio::time::advance(Duration::from_millis(100)).await; - tokio::task::yield_now().await; - } - backend.started(HotspotSecurity::Wpa2); - for _ in 0..8 { - tokio::time::advance(Duration::from_millis(100)).await; - tokio::task::yield_now().await; - } - // The answer was sent by then, so the take gets it at once. - let answer = answer.try_recv().unwrap(); - assert_eq!( - answer, - Ok(HotspotOffer::new( - "hotspot", - "secret", - HotspotSecurity::Wpa2, - Some(54321) - )) - ); - drop(tx); - for _ in 0..8 { - tokio::time::advance(Duration::from_millis(100)).await; - tokio::task::yield_now().await; - } - tokio::time::timeout(Duration::from_millis(1000), joined) - .await - .expect("the loop ends with its channel") - .expect("the loop task ends"); - } - /// The offer's debug form hides its passphrase. #[test] fn the_offer_hides_its_passphrase_in_its_debug_form() { diff --git a/crates/connetto-client/src/hotspot/android.rs b/crates/connetto-client/src/hotspot/android.rs index f1383f10..85b48d7d 100644 --- a/crates/connetto-client/src/hotspot/android.rs +++ b/crates/connetto-client/src/hotspot/android.rs @@ -49,17 +49,17 @@ fn sdk() -> u32 { .unwrap_or(0) } -/// A failure reaching the plugin through the Java VM. +/// A failure reaching a peer plugin through the Java VM. #[derive(Debug, thiserror::Error)] -enum PluginFailure { +pub(crate) enum PluginFailure { /// A refusal from the Java side, kept as the exception's text, else the JNI error's. - #[error("the hotspot plugin refused: {0}")] + #[error("the peer plugin refused: {0}")] Java(String), /// The process has no reachable Java VM. - #[error("the hotspot plugin will not run: {0}")] + #[error("the peer plugin will not run: {0}")] NoVm(String), /// The application's JNI access returned without running the body. - #[error("the hotspot plugin will not run: the JNI access ran nothing")] + #[error("the peer plugin will not run: the JNI access ran nothing")] RanNothing, } @@ -92,7 +92,7 @@ fn join_failure(failure: PluginFailure) -> JoinError { } /// The pending Java exception's text, cleared, else the JNI error's. -fn exception_text(env: &mut JNIEnv<'_>, err: &jni::errors::Error) -> PluginFailure { +pub(crate) fn exception_text(env: &mut JNIEnv<'_>, err: &jni::errors::Error) -> PluginFailure { let pending = env.exception_occurred().ok().filter(|ex| !ex.is_null()); let _ = env.exception_clear(); let text = pending @@ -106,7 +106,7 @@ fn exception_text(env: &mut JNIEnv<'_>, err: &jni::errors::Error) -> PluginFailu } /// Run `body` on the device's Java VM, a pending exception becoming its text. -fn call( +pub(crate) fn call( java: &dyn JavaAccess, body: impl FnOnce(&mut JNIEnv<'_>) -> jni::errors::Result, ) -> Result { @@ -123,8 +123,11 @@ fn call( } } -/// The plugin's class, on the application's class loader. -fn plugin<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result> { +/// The `class` on the application's class loader. +pub(crate) fn plugin<'local>( + env: &mut JNIEnv<'local>, + class_name: &str, +) -> jni::errors::Result> { let application = env .call_static_method( "android/app/ActivityThread", @@ -141,7 +144,7 @@ fn plugin<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result(env: &mut JNIEnv<'local>) -> jni::errors::Result, class: &JClass<'_>, name: &str) -> jni::errors::Result { +pub(crate) fn int( + env: &mut JNIEnv<'_>, + class: &JClass<'_>, + name: &str, +) -> jni::errors::Result { env.call_static_method(class, name, "()I", &[])?.i() } /// A plugin `()Ljava/lang/String;` result, `None` where it is unset. -fn string( +pub(crate) fn string( env: &mut JNIEnv<'_>, class: &JClass<'_>, name: &str, @@ -203,7 +210,7 @@ impl HotspotBackend for AndroidHotspotBackend { return Err(HotspotError::Unsupported); } let missing = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; missing_permissions(env, &class) }) .map_err(host_failure)?; @@ -211,7 +218,7 @@ impl HotspotBackend for AndroidHotspotBackend { return Err(HotspotError::MissingPermission(name)); } call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; env.call_static_method(class, "startHost", "()V", &[])?; Ok(()) }) @@ -220,7 +227,7 @@ impl HotspotBackend for AndroidHotspotBackend { fn stop_host(&self) { let _ = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; env.call_static_method(class, "stopHost", "()V", &[])?; Ok(()) }); @@ -228,7 +235,7 @@ impl HotspotBackend for AndroidHotspotBackend { fn host_status(&self) -> HostStatus { let outcome = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; Ok(( int(env, &class, "hostState")?, string(env, &class, "hostSsid")?, @@ -281,7 +288,7 @@ impl HotspotBackend for AndroidHotspotBackend { return Err(JoinError::Unsupported); } let missing = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; missing_permissions(env, &class) }) .map_err(join_failure)?; @@ -294,7 +301,7 @@ impl HotspotBackend for AndroidHotspotBackend { SECURITY_WPA2 }; call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; let ssid = env.new_string(&offer.ssid)?; let passphrase = env.new_string(&offer.passphrase)?; env.call_static_method( @@ -315,7 +322,7 @@ impl HotspotBackend for AndroidHotspotBackend { fn leave_join(&self) { let _ = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; env.call_static_method(class, "leave", "()V", &[])?; Ok(()) }); @@ -323,7 +330,7 @@ impl HotspotBackend for AndroidHotspotBackend { fn join_status(&self) -> JoinStatus { let outcome = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; Ok(( int(env, &class, "joinState")?, string(env, &class, "joinAddress")?, @@ -387,7 +394,7 @@ impl connetto_peer::SocketPrep for JoinedBind { } let fd = sock.as_fd().as_raw_fd(); let bound = call(&*self.java, |env| { - let class = plugin(env)?; + let class = plugin(env, PLUGIN_CLASS)?; let bound = env .call_static_method(class, "bindSocket", "(I)Z", &[JValue::from(fd)])? .z()?; diff --git a/crates/connetto-client/src/lib.rs b/crates/connetto-client/src/lib.rs index 891c609f..217b2e1b 100644 --- a/crates/connetto-client/src/lib.rs +++ b/crates/connetto-client/src/lib.rs @@ -76,6 +76,8 @@ pub mod archive; #[cfg(feature = "native-auth")] pub mod auth; pub mod away; +#[cfg(feature = "peer")] +mod bluetooth; pub mod builder; pub mod cipher; mod clock; @@ -98,6 +100,10 @@ pub mod reconnect; pub mod replica; mod subscriptions; +#[cfg(feature = "peer")] +pub use bluetooth::{ + BeaconState, BluetoothError, BluetoothState, HostId, Hosted, JoinNearbyError, PromptOutcome, +}; #[cfg(feature = "device-identity")] pub use enrolment::{CertificateError, DeviceEntry}; #[cfg(feature = "peer")] @@ -1265,6 +1271,29 @@ pub enum ClientEvent { /// link standing, so it stopped (R76). #[cfg(feature = "peer")] JoinedHostUnreached, + /// The platform's Bluetooth standing changed (R76 decision 21). + #[cfg(feature = "peer")] + BluetoothChanged { + /// The standing the platform reports. + state: crate::bluetooth::BluetoothState, + }, + /// A nearby host's beacon was seen, or its prefix changed (R76 decision + /// 19). + #[cfg(feature = "peer")] + HostNearby { + /// The host's device. + host: crate::bluetooth::HostId, + /// The beacon's prefix. + prefix: [u8; 8], + /// The signal the beacon carried. + rssi: Option, + }, + /// A nearby host's beacon was not seen for its bound (R76 decision 19). + #[cfg(feature = "peer")] + HostGone { + /// The host's device. + host: crate::bluetooth::HostId, + }, } /// A primary-key column value carried on a mutation event. diff --git a/crates/connetto-client/tests/it/enrolment.rs b/crates/connetto-client/tests/it/enrolment.rs index a3602862..d54854c6 100644 --- a/crates/connetto-client/tests/it/enrolment.rs +++ b/crates/connetto-client/tests/it/enrolment.rs @@ -38,7 +38,7 @@ use connetto_test_harness::{ ConnettoDefaults, Fixture, MOCK_OAUTH_PROVIDER, MockOauth, RosterAuth, }; use openidconnect::reqwest; -use rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256}; +use rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256, PublicKeyData}; use tempfile::tempdir; /// The longest any bounded wait in a phase runs. @@ -145,8 +145,8 @@ async fn enrolment_phase() { "offline" => offline().await, "forget" => forget().await, "report" => Box::pin(report()).await, - "learned" => learned_at_connect().await, - "untrusted" => untrusted().await, + "learned" => Box::pin(learned_at_connect()).await, + "untrusted" => Box::pin(untrusted()).await, "no-roots" => no_roots().await, "rotation" => Box::pin(rotation_withdraws()).await, "clock-behind" => Box::pin(clock_behind()).await, @@ -162,8 +162,8 @@ async fn enrolment_phase() { /// An issuer valid for a year and a month from now, under a fresh root. /// /// The key is made with the suite's own `rcgen` and the issuer loads it from -/// PKCS #8 bytes, so the two `rcgen` majors in the graph never meet as one -/// value. +/// PKCS #8 bytes, so the suite's key pair never crosses the crate boundary as +/// a `KeyPair` value. fn issuer() -> DeviceIssuer { let (root, cert, key) = authority(); DeviceIssuer::from_pkcs8(cert.clone(), key, root).expect("load") @@ -189,11 +189,21 @@ static ROTATION: std::sync::LazyLock = std::sync::LazyLock::new(|| { .expect("root"); let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("issuer key"); let cert = root - .sign_issuer(&key.public_key_der(), now - DAY, 395 * DAY, [1; 16]) + .sign_issuer( + &key.subject_public_key_info(), + now - DAY, + 395 * DAY, + [1; 16], + ) .expect("issuer"); let next_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("next issuer key"); let next_cert = root - .sign_issuer(&next_key.public_key_der(), now - DAY, 395 * DAY, [2; 16]) + .sign_issuer( + &next_key.subject_public_key_info(), + now - DAY, + 395 * DAY, + [2; 16], + ) .expect("next issuer"); let root_list = root .sign_list( diff --git a/crates/connetto-peer-android/android/src/main/AndroidManifest.xml b/crates/connetto-peer-android/android/src/main/AndroidManifest.xml index 804660b9..290f3120 100644 --- a/crates/connetto-peer-android/android/src/main/AndroidManifest.xml +++ b/crates/connetto-peer-android/android/src/main/AndroidManifest.xml @@ -13,4 +13,18 @@ + + + + + + diff --git a/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt new file mode 100644 index 00000000..586b7193 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt @@ -0,0 +1,532 @@ +package dev.connetto.peer + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.ParcelUuid +import java.util.Base64 +import java.util.UUID + +/** + * The Bluetooth beacon and the GATT service the exchange runs over (R76). + * + * All state stands behind [lock]; the [JvmStatic]s are the JNI surface the + * client drives. The readiness stands on the adapter's switch and the + * three runtime permissions. The host side starts a legacy advertisement + * carrying the beacon's service data under the service uuid, and a GATT + * server with the characteristic the joiner writes and the one it reads + * notifications from. The client polls [poll] for the events, whose lines + * are tab-separated and carry their bytes base64-encoded. + */ +class BluetoothPlugin { + companion object { + // The service the GATT server stands under, and the characteristics + // the joiner writes to and reads notifications from (R76 decision 18). + private val SERVICE_UUID = UUID.fromString("a9952637-85d5-4071-9ed8-c28bd7ba670c") + private val INBOX_UUID = UUID.fromString("bdd3f20f-a8d0-4003-82db-4f941b4c375b") + private val OUTBOX_UUID = UUID.fromString("62ba3a58-c377-47f5-93e1-cd6790bc2e53") + + // The client characteristic configuration the joiner writes to + // turn its notifications on. + private val CCC_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // The ATT packet size a connection has before any exchange. + private const val DEFAULT_MTU = 23 + + // The readiness, as the client reads it. + private const val STATE_UNSUPPORTED = 0 + private const val STATE_READY = 1 + private const val STATE_OFF = 2 + private const val STATE_NOT_PERMITTED = 3 + + // The finished prompt action's result, and the in-flight marker. + private const val OUTCOME_NOT_ASKED = 0 + private const val OUTCOME_DECLINED = 1 + private const val OUTCOME_SENT_TO_SETTINGS = 2 + private const val OUTCOME_BLOCKED = 3 + private const val OUTCOME_IN_FLIGHT = -1 + + // The prompt action's start. + private const val PROMPT_STARTED = 0 + private const val PROMPT_BLOCKED = 1 + + private const val OK = 0 + private const val FAILED = 1 + + private const val PERMISSION_REQUEST = 2001 + private const val PENDING_NONE = 0 + private const val PENDING_PERMISSIONS = 1 + private const val PENDING_ENABLE = 2 + + // The event lines' codes. + private const val EVENT_CONNECTED = 1 + private const val EVENT_DISCONNECTED = 2 + private const val EVENT_FAILED = 3 + private const val EVENT_CHUNK = 4 + + private val lock = Any() + + private var cachedContext: Context? = null + private var callbacksRegistered = false + private var resumedActivity: Activity? = null + private var pendingPrompt = PENDING_NONE + private var promptOutcome = OUTCOME_IN_FLIGHT + + private var advertising = false + private var gattServer: BluetoothGattServer? = null + private var outbox: BluetoothGattCharacteristic? = null + private val devices = HashMap() + private val mtus = HashMap() + private val connected = HashMap() + private val eventLines = ArrayDeque() + private val notifying = HashMap() + private val notifyQueue = HashMap>() + private val ccc = HashMap() + + @JvmStatic + fun state(): Int = synchronized(lock) { + val adapter = adapter() ?: return STATE_UNSUPPORTED + if (!adapter.isEnabled) return STATE_OFF + if (missingPermissionsLocked().isNotEmpty()) return STATE_NOT_PERMITTED + STATE_READY + } + + // Joined with newlines, so the JNI side, which cannot safely build + // the array's constructor, splits on lines. + @JvmStatic + fun missingPermissions(): String = + synchronized(lock) { missingPermissionsLocked().joinToString("\n") } + + @JvmStatic + fun prompt(): Int = synchronized(lock) { + promptOutcome = OUTCOME_IN_FLIGHT + val adapter = adapter() ?: return PROMPT_BLOCKED + registerLifecycleLocked() + val missing = missingPermissionsLocked() + if (missing.isEmpty() && adapter.isEnabled) { + promptOutcome = OUTCOME_NOT_ASKED + return PROMPT_STARTED + } + val activity = resumedActivity ?: run { + promptOutcome = OUTCOME_BLOCKED + return PROMPT_BLOCKED + } + if (missing.isNotEmpty()) { + activity.requestPermissions(missing.toTypedArray(), PERMISSION_REQUEST) + pendingPrompt = PENDING_PERMISSIONS + return PROMPT_STARTED + } + return try { + activity.startActivityForResult( + Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), + PERMISSION_REQUEST + ) + pendingPrompt = PENDING_ENABLE + PROMPT_STARTED + } catch (e: Exception) { + promptOutcome = OUTCOME_BLOCKED + PROMPT_BLOCKED + } + } + + @JvmStatic + fun promptOutcome(): Int = synchronized(lock) { promptOutcome } + + @JvmStatic + fun startAdvertising(beacon: ByteArray): Int = synchronized(lock) { + stopLocked() + val manager = manager() ?: return FAILED + val service = BluetoothGattService( + SERVICE_UUID, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) + val inbox = BluetoothGattCharacteristic( + INBOX_UUID, + BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE + ) + service.addCharacteristic(inbox) + val out = BluetoothGattCharacteristic( + OUTBOX_UUID, + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_READ + ) + out.addDescriptor(cccDescriptor()) + service.addCharacteristic(out) + val server = manager.openGattServer(context(), gattCallback) ?: return FAILED + if (!server.addService(service)) { + server.close() + return FAILED + } + gattServer = server + outbox = out + val advertiser = manager.adapter?.bluetoothLeAdvertiser ?: return FAILED + val settings = AdvertiseSettings.Builder() + .setConnectable(true) + .build() + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceData(ParcelUuid(SERVICE_UUID), beacon) + .build() + advertising = false + advertiser.startAdvertising(settings, data, advertiseCallback) + OK + } + + @JvmStatic + fun stopAdvertising() { + synchronized(lock) { stopLocked() } + } + + @JvmStatic + fun poll(): String = synchronized(lock) { + val lines = eventLines.joinToString("\n") + eventLines.clear() + lines + } + + @JvmStatic + fun notify(device: Long, bytes: ByteArray): Int = synchronized(lock) { + val server = gattServer ?: return FAILED + val target = devices[device] ?: return FAILED + val characteristic = outbox ?: return FAILED + val queue = notifyQueue.getOrPut(device) { ArrayDeque() } + if (notifying.getOrDefault(device, false)) { + // The platform allows one notification in flight per device, + // so the next goes when the platform's sent callback fires. + queue.addLast(bytes) + return OK + } + val status = server.notifyCharacteristicChanged( + target, + characteristic, + false, + bytes + ) + if (status != BluetoothGatt.GATT_SUCCESS) { + dropLocked(device) + return FAILED + } + notifying[device] = true + OK + } + + // A lost notification leaves a gap in the ordered byte stream the + // exchange reads, so the connection ends at once and is reported. + private fun dropLocked(key: Long) { + val target = devices.remove(key) ?: return + connected.remove(key) + mtus.remove(key) + notifying.remove(key) + notifyQueue.remove(key) + ccc.remove(key) + eventLines.addLast("$EVENT_DISCONNECTED\t$key") + runCatching { gattServer?.cancelConnection(target) } + } + + @JvmStatic + fun disconnectPeripheral(device: Long) { + synchronized(lock) { + val target = devices[device] ?: return + runCatching { gattServer?.cancelConnection(target) } + } + } + + private fun missingPermissionsLocked(): List { + val context = context() ?: return emptyList() + if (Build.VERSION.SDK_INT < 31) return emptyList() + val names = listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + ) + return names + .filter { + context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + .map { it.substringAfterLast('.') } + } + + private fun registerLifecycleLocked() { + if (callbacksRegistered) return + val application = context()?.applicationContext as? Application ?: return + application.registerActivityLifecycleCallbacks(lifecycleCallbacks) + callbacksRegistered = true + } + + private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { + synchronized(lock) { + resumedActivity = activity + if (pendingPrompt == PENDING_NONE) return + pendingPrompt = PENDING_NONE + val ready = missingPermissionsLocked().isEmpty() && + adapter()?.isEnabled == true + promptOutcome = + if (ready) OUTCOME_NOT_ASKED else OUTCOME_DECLINED + } + } + + override fun onActivityDestroyed(activity: Activity) { + synchronized(lock) { + if (resumedActivity === activity) resumedActivity = null + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: android.os.Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: android.os.Bundle) {} + } + + private val advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settings: AdvertiseSettings) { + synchronized(lock) { advertising = true } + } + + override fun onStartFailure(status: Int) { + synchronized(lock) { + advertising = false + eventLines.addLast("$EVENT_FAILED\t$status") + } + } + } + + private val gattCallback = object : BluetoothGattServerCallback() { + override fun onConnectionStateChange( + device: BluetoothDevice?, + status: Int, + newState: Int + ) { + if (device == null) return + val key = deviceKey(device) + synchronized(lock) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + devices[key] = device + connected[key] = true + // The client starts the exchange on the negotiated + // packet size, so the line waits for onMtuChanged. + } else { + // A connection a failed notification ended is + // already reported. + if (devices.remove(key) == null) return + connected.remove(key) + mtus.remove(key) + notifying.remove(key) + notifyQueue.remove(key) + ccc.remove(key) + eventLines.addLast("$EVENT_DISCONNECTED\t$key") + } + } + } + + override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) { + if (device == null) return + val key = deviceKey(device) + synchronized(lock) { + val first = !mtus.containsKey(key) + mtus[key] = mtu + if (first && connected[key] == true) { + eventLines.addLast("$EVENT_CONNECTED\t$key\t$mtu") + } + } + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice?, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray? + ) { + if (device == null || value == null) return + if (characteristic.uuid != INBOX_UUID) return + val key = deviceKey(device) + synchronized(lock) { + // A client that never exchanged a packet size writes on + // the default one, so the connection is reported first. + if (!mtus.containsKey(key) && connected[key] == true) { + mtus[key] = DEFAULT_MTU + eventLines.addLast("$EVENT_CONNECTED\t$key\t$DEFAULT_MTU") + } + eventLines.addLast( + "$EVENT_CHUNK\t$key\t" + + Base64.getEncoder().encodeToString(value) + ) + } + if (responseNeeded) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + null + ) + } + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice?, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic + ) { + if (device == null) return + // The joiner streams over the notifications, so the read + // answers with the empty value. + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + null + ) + } + + override fun onDescriptorReadRequest( + device: BluetoothDevice?, + requestId: Int, + offset: Int, + descriptor: BluetoothGattDescriptor + ) { + if (device == null) return + if (descriptor.uuid != CCC_UUID) return + val key = deviceKey(device) + val state = synchronized(lock) { + ccc[key] ?: BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE + } + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + state + ) + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice?, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray? + ) { + if (device == null || value == null) return + if (descriptor.uuid != CCC_UUID) return + val key = deviceKey(device) + // The write turns the device's notifications on or off, + // and the read-back stands on the stored value. + synchronized(lock) { ccc[key] = value } + if (responseNeeded) { + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + null + ) + } + } + + override fun onNotificationSent(device: BluetoothDevice?, status: Int) { + if (device == null) return + val key = deviceKey(device) + synchronized(lock) { + if (status != BluetoothGatt.GATT_SUCCESS) { + dropLocked(key) + return + } + notifying[key] = false + val next = notifyQueue[key]?.removeFirstOrNull() ?: return + val server = gattServer ?: return + val characteristic = outbox ?: return + if (server.notifyCharacteristicChanged( + device, + characteristic, + false, + next + ) == BluetoothGatt.GATT_SUCCESS + ) { + notifying[key] = true + } else { + dropLocked(key) + } + } + } + } + + private fun stopLocked() { + runCatching { + val manager = manager() + manager?.adapter?.bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback) + } + advertising = false + gattServer?.close() + gattServer = null + outbox = null + devices.clear() + connected.clear() + mtus.clear() + notifying.clear() + notifyQueue.clear() + ccc.clear() + } + + private fun cccDescriptor(): BluetoothGattDescriptor = BluetoothGattDescriptor( + CCC_UUID, + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE + ) + + private fun deviceKey(device: BluetoothDevice): Long { + val mac = device.address + var key = 0L + for (i in 0 until 6) { + key = (key shl 8) or mac.substring(i * 3, i * 3 + 2).toLong(16) + } + return key + } + + private fun manager(): BluetoothManager? = + context()?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + + private fun adapter(): BluetoothAdapter? = manager()?.adapter + + private fun context(): Context? { + cachedContext?.let { return it } + val context = contextFromFramework() + if (context != null) cachedContext = context + return context + } + + private fun contextFromFramework(): Context? = runCatching { + val application = + Class.forName("android.app.ActivityThread") + .getMethod("currentApplication") + .invoke(null) + application as? Context + }.getOrNull() + } +} diff --git a/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt index f83da07a..a89b49f1 100644 --- a/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt +++ b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/HotspotPlugin.kt @@ -312,12 +312,23 @@ class HotspotPlugin { // The prompt's activity stands the context's public route, so the // cache seeds from it for the JNI-threaded calls. cachedContext = activity.applicationContext - val missing = missingPermissions() + val missing = missingPermissions().split("\n").filter { it.isNotEmpty() }.toMutableList() + if (Build.VERSION.SDK_INT >= 31) { + // The beacon and the exchange ask for theirs beside the + // hotspot's own, so the one prompt covers the peer link, + // while hosting and joining check only their own. + for (name in listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + )) { + if (activity.checkSelfPermission(name) != PackageManager.PERMISSION_GRANTED) { + missing += name + } + } + } if (missing.isNotEmpty()) { - activity.requestPermissions( - missing.split("\n").toTypedArray(), - PERMISSION_REQUEST - ) + activity.requestPermissions(missing.toTypedArray(), PERMISSION_REQUEST) } } diff --git a/crates/connetto-peer-android/src/android.rs b/crates/connetto-peer-android/src/android.rs index 372ee7aa..b9abaa9a 100644 --- a/crates/connetto-peer-android/src/android.rs +++ b/crates/connetto-peer-android/src/android.rs @@ -9,6 +9,7 @@ use manganis::jni::objects::{JClass, JValue}; #[manganis::ffi("android")] extern "Kotlin" { pub type HotspotPlugin; + pub type BluetoothPlugin; } /// The plugin's class, reached through the application's class loader, since diff --git a/crates/connetto-peer/Cargo.toml b/crates/connetto-peer/Cargo.toml index b20715ce..91bb3e29 100644 --- a/crates/connetto-peer/Cargo.toml +++ b/crates/connetto-peer/Cargo.toml @@ -20,6 +20,9 @@ default = [] # The mDNS advertisement and browse beside the policy that dials what they # find, and the leaf fingerprint the TXT record carries (R76 slice 3). discovery = ["dep:mdns-sd", "dep:ring"] +# The Bluetooth beacon and the exchange over a chunk transport, behind the +# leaf fingerprint the beacon's prefix takes (R76 slice 5). +bluetooth = ["discovery"] [dependencies] connetto-core = { workspace = true, features = ["device-cert"] } diff --git a/crates/connetto-peer/src/beacon.rs b/crates/connetto-peer/src/beacon.rs new file mode 100644 index 00000000..24bd9972 --- /dev/null +++ b/crates/connetto-peer/src/beacon.rs @@ -0,0 +1,140 @@ +//! The beacon the host advertises over Bluetooth while it hosts a hotspot +//! and serves its peer link (R76 decision 18). + +use crate::fingerprint::Fingerprint; +use crate::frame::PROTOCOL_VERSION; + +// The frame version must fit the beacon's one version byte, and a value that +// no longer fits stops the crate compiling. +const _: () = assert!( + // Widening, so the bound compares losslessly in a const context. + PROTOCOL_VERSION <= u8::MAX as u16, + "the beacon's version byte fits the frame version" +); + +/// The frame version the beacon's service data carries. +#[expect( + clippy::cast_possible_truncation, + reason = "the assertion above bounds the version to a byte" +)] +const VERSION: u8 = PROTOCOL_VERSION as u8; + +/// The GATT service the beacon advertises and the exchange runs under, +/// `a9952637-85d5-4071-9ed8-c28bd7ba670c`. +pub const SERVICE_UUID: [u8; 16] = [ + 0xa9, 0x95, 0x26, 0x37, 0x85, 0xd5, 0x40, 0x71, 0x9e, 0xd8, 0xc2, 0x8b, 0xd7, 0xba, 0x67, 0x0c, +]; +/// The characteristic the joiner writes its chunks to, +/// `bdd3f20f-a8d0-4003-82db-4f941b4c375b`. +pub const INBOX_UUID: [u8; 16] = [ + 0xbd, 0xd3, 0xf2, 0x0f, 0xa8, 0xd0, 0x40, 0x03, 0x82, 0xdb, 0x4f, 0x94, 0x1b, 0x4c, 0x37, 0x5b, +]; +/// The characteristic the joiner reads its chunks from, +/// `62ba3a58-c377-47f5-93e1-cd6790bc2e53`. +pub const OUTBOX_UUID: [u8; 16] = [ + 0x62, 0xba, 0x3a, 0x58, 0xc3, 0x77, 0x47, 0xf5, 0x93, 0xe1, 0xcd, 0x67, 0x90, 0xbc, 0x2e, 0x53, +]; + +/// The advertised beacon, the frame version beside the first 8 bytes of the +/// presented leaf's fingerprint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Beacon { + /// The frame version the host speaks. + pub version: u8, + /// The first 8 bytes of the presented leaf's fingerprint. + pub prefix: [u8; 8], +} + +impl Beacon { + /// The beacon of the presented leaf's `fingerprint`. + #[must_use] + pub fn of(fingerprint: &Fingerprint) -> Self { + Self { + version: VERSION, + prefix: fingerprint.prefix(), + } + } + + /// The 9 service-data bytes, the version ahead of the prefix. + #[must_use] + pub fn to_service_data(&self) -> [u8; 9] { + let mut data = [0u8; 9]; + data[0] = self.version; + data[1..].copy_from_slice(&self.prefix); + data + } + + /// Decode the 9 service-data bytes a scan of this service carries, + /// `None` when the slice is short. + #[must_use] + pub fn from_service_data(data: &[u8]) -> Option { + let version = *data.first()?; + let mut prefix = [0u8; 8]; + prefix.copy_from_slice(data.get(1..9)?); + Some(Self { version, prefix }) + } + + /// Whether the beacon is `fingerprint`'s, the prefix the only thing a + /// scan carries of it. + #[must_use] + pub fn matches(&self, fingerprint: &Fingerprint) -> bool { + self.prefix == fingerprint.prefix() + } +} + +/// The whole legacy advertisement, the flags beside the one service-data AD +/// under [`SERVICE_UUID`], test-only. +#[cfg(test)] +pub(crate) fn legacy_advert_bytes(beacon: &Beacon) -> [u8; 30] { + let mut bytes = [0u8; 30]; + // The flags take the three reserved bytes, broadcast, limited + // discoverable. + bytes[0..3].copy_from_slice(&[0x01, 0x02, 0x06]); + // The service-data AD, its length counting the type, the 16 UUID bytes + // and the 9 data bytes. + bytes[3] = 26; + bytes[4] = 0x21; + bytes[5..21].copy_from_slice(&SERVICE_UUID); + bytes[21..30].copy_from_slice(&beacon.to_service_data()); + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_service_data_round_trips_and_names_the_prefix() { + let fingerprint = Fingerprint::of(b"the presented leaf"); + let beacon = Beacon::of(&fingerprint); + assert_eq!(beacon.version, 1); + assert_eq!(beacon.prefix, fingerprint.prefix()); + + let data = beacon.to_service_data(); + assert_eq!(data.len(), 9); + assert_eq!(data[0], beacon.version); + assert_eq!(&data[1..], &fingerprint.prefix()); + + let decoded = Beacon::from_service_data(&data).expect("the service data decodes"); + assert_eq!(decoded, beacon); + assert!(decoded.matches(&fingerprint)); + let other = Fingerprint::of(b"another leaf"); + assert!(!decoded.matches(&other)); + + assert!(Beacon::from_service_data(&data[..8]).is_none()); + assert!(Beacon::from_service_data(&[]).is_none()); + } + + #[test] + fn the_legacy_advert_fits_its_31_bytes() { + let beacon = Beacon::of(&Fingerprint::of(b"the presented leaf")); + let advert = legacy_advert_bytes(&beacon); + // A legacy advertisement is 31 bytes including the three reserved, + // and the flags plus the one service-data AD use 30. + assert!(advert.len() <= 31); + assert_eq!(&advert[0..3], &[0x01, 0x02, 0x06]); + assert_eq!(&advert[3..5], &[26, 0x21]); + assert_eq!(&advert[5..21], &SERVICE_UUID); + assert_eq!(&advert[21..30], &beacon.to_service_data()); + } +} diff --git a/crates/connetto-peer/src/error.rs b/crates/connetto-peer/src/error.rs index 5d42bfc3..60d8562b 100644 --- a/crates/connetto-peer/src/error.rs +++ b/crates/connetto-peer/src/error.rs @@ -103,3 +103,53 @@ pub enum LinkError { #[error("tls failed")] Tls(#[source] rustls::Error), } + +/// Why a Bluetooth exchange failed. +#[cfg(feature = "bluetooth")] +#[derive(Debug, thiserror::Error)] +pub enum ExchangeError { + /// The node holds no identity to present. + #[error("the node holds no identity to present")] + NotServing, + /// The node's own verifier refused the peer's chain, with the reason. + #[error("the verifier refused the peer")] + Refused(Refusal), + /// The peer's TLS alert refused the exchange. + #[error("the peer refused the exchange")] + RefusedByPeer(AlertDescription), + /// The peer speaks a different frame version. + #[error("the peer speaks protocol version {their}")] + UnsupportedVersion { + /// The version the peer offered. + their: u16, + }, + /// A frame broke the protocol. + #[error("the peer broke the frame protocol")] + Protocol(String), + /// The exchange ran out of its bound. + #[error("the exchange took too long to answer")] + Timeout, + /// A TLS failure without a typed reason. + #[error("tls failed")] + Tls(#[source] rustls::Error), + /// The exchange's I/O failed. + #[error("the exchange's I/O failed")] + Io(#[source] std::io::Error), +} + +/// A link failure as the exchange's typed error, keeping every distinction. +#[cfg(feature = "bluetooth")] +impl From for ExchangeError { + fn from(error: LinkError) -> Self { + match error { + LinkError::NotServing => Self::NotServing, + LinkError::Unreachable(source) => Self::Io(source), + LinkError::Timeout => Self::Timeout, + LinkError::Refused(refusal) => Self::Refused(refusal), + LinkError::RefusedByPeer(alert) => Self::RefusedByPeer(alert), + LinkError::UnsupportedVersion { their } => Self::UnsupportedVersion { their }, + LinkError::Protocol(message) => Self::Protocol(message), + LinkError::Tls(source) => Self::Tls(source), + } + } +} diff --git a/crates/connetto-peer/src/exchange.rs b/crates/connetto-peer/src/exchange.rs new file mode 100644 index 00000000..506b8259 --- /dev/null +++ b/crates/connetto-peer/src/exchange.rs @@ -0,0 +1,398 @@ +//! The Bluetooth exchange, the link's TLS 1.3 and list swap over a chunk +//! transport, and the offer the host hands the joiner (R76 slice 5). + +use std::fmt; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use connetto_core::device_cert::{DeviceCertificate, DeviceIdentity}; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::sync::mpsc; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +use crate::error::ExchangeError; +use crate::event::PeerEvent; +use crate::frame::{PeerFrame, read_frame, write_frame}; +use crate::identity::Identity; +use crate::node::{ + DIAL_NAME, NodeState, classify_frame, classify_io, client_config_for, exchange_hello, + server_config_for, +}; + +/// The bound the whole exchange gets, on the monotonic clock. +pub const EXCHANGE_BOUND: Duration = Duration::from_secs(30); + +/// A byte stream over ordered chunks of at most `chunk_size` bytes each +/// way, the transport a GATT characteristic is. +#[derive(Debug)] +pub struct ChunkStream { + inbound: mpsc::Receiver>, + /// The bridge task's input, closed once the writes end. + tx: Option>>, + chunk: usize, + /// The inbound bytes not yet handed to a read. + pending: Vec, +} + +impl ChunkStream { + /// Stream over `inbound`, the peer's chunks in order, writing into + /// `outbound` split to `chunk_size`, which must be at least 1. A closed + /// `inbound` ends the reads, and a dropped `outbound` ends the writes. + /// + /// A bridge task owns `outbound` and keeps the channel's backpressure, + /// so a runtime must be present. + /// + /// # Panics + /// + /// When `chunk_size` is 0, or no tokio runtime drives the bridge. + #[must_use] + pub fn new( + inbound: mpsc::Receiver>, + outbound: mpsc::Sender>, + chunk_size: usize, + ) -> Self { + assert!(chunk_size > 0, "a chunk is at least one byte"); + let (tx, rx) = mpsc::unbounded_channel(); + tokio::spawn(bridge(outbound, rx)); + Self { + inbound, + tx: Some(tx), + chunk: chunk_size, + pending: Vec::new(), + } + } +} + +/// The outbound bridge, keeping the real channel's backpressure until the +/// writes end or the reader is gone. +async fn bridge(outbound: mpsc::Sender>, mut rx: mpsc::UnboundedReceiver>) { + while let Some(chunk) = rx.recv().await { + if outbound.send(chunk).await.is_err() { + break; + } + } +} + +impl AsyncRead for ChunkStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + // An empty chunk carries no bytes, so only a closed inbound side ends + // the stream. + while this.pending.is_empty() { + match this.inbound.poll_recv(cx) { + Poll::Ready(Some(chunk)) => this.pending = chunk, + // The peer's chunks are done, and with them the stream. + Poll::Ready(None) => return Poll::Ready(Ok(())), + Poll::Pending => return Poll::Pending, + } + } + let take = this.pending.len().min(buf.remaining()); + buf.put_slice(&this.pending[..take]); + this.pending.drain(..take); + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for ChunkStream { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + let Some(tx) = this.tx.as_ref() else { + return Poll::Ready(Err(closed())); + }; + // The last chunk may be short, the peer rejoins the chunks into the + // byte stream. + for chunk in buf.chunks(this.chunk) { + if tx.send(chunk.to_vec()).is_err() { + return Poll::Ready(Err(closed())); + } + } + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + // The writes are already with the bridge, so a live bridge is a + // live flush. + if self.tx.is_some() { + Poll::Ready(Ok(())) + } else { + Poll::Ready(Err(closed())) + } + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + // The last sender's drop ends the bridge after its queue drains, and + // with it the peer's reads. + self.get_mut().tx = None; + Poll::Ready(Ok(())) + } +} + +/// A write into a stream whose chunk reader is gone. +fn closed() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "the chunk reader is gone") +} + +/// The hotspot details the host hands the joiner once both have proved +/// their identity. +#[derive(Clone, PartialEq, Eq)] +pub struct OfferFrame { + /// The network name. + pub ssid: String, + /// The passphrase, redacted in the debug form and zeroed on drop. + passphrase: Vec, + /// The security the network carries, 0 for WPA2 and 1 for WPA3. + pub security: u8, + /// The peer port the host serves, while it serves. + pub port: Option, +} + +impl OfferFrame { + /// An offer with its details. + #[must_use] + pub fn new( + ssid: impl Into, + passphrase: impl Into, + security: u8, + port: Option, + ) -> Self { + Self { + ssid: ssid.into(), + passphrase: passphrase.into().into_bytes(), + security, + port, + } + } + + /// The passphrase. + /// + /// # Panics + /// + /// Never, a passphrase is built from a string. + #[must_use] + pub fn passphrase(&self) -> &str { + std::str::from_utf8(&self.passphrase).expect("a passphrase is built from a string") + } + + /// The passphrase as the frame carries it. + fn wire_passphrase(&self) -> String { + String::from_utf8(self.passphrase.clone()).expect("a passphrase is built from a string") + } +} + +impl fmt::Debug for OfferFrame { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OfferFrame") + .field("ssid", &self.ssid) + .field("passphrase", &"") + .field("security", &self.security) + .field("port", &self.port) + .finish() + } +} + +impl Drop for OfferFrame { + fn drop(&mut self) { + self.passphrase.fill(0); + self.passphrase.clear(); + } +} + +/// Run the exchange as the host over `io`, handing the joiner `offer` once +/// it proves its identity. +pub(crate) async fn offer_exchange( + state: &NodeState, + io: S, + identity: &Identity, + offer: &OfferFrame, +) -> Result { + tokio::time::timeout(EXCHANGE_BOUND, host_exchange(state, io, identity, offer)) + .await + .map_err(|_| ExchangeError::Timeout)? +} + +/// Run the exchange as the joiner over `io`, answering the host's identity +/// and its offer. +pub(crate) async fn fetch_exchange( + state: &NodeState, + io: S, + identity: &Identity, +) -> Result<(DeviceIdentity, OfferFrame), ExchangeError> { + tokio::time::timeout(EXCHANGE_BOUND, joiner_exchange(state, io, identity)) + .await + .map_err(|_| ExchangeError::Timeout)? +} + +/// The host's side, the listener's side of a link without the registration. +async fn host_exchange( + state: &NodeState, + io: S, + identity: &Identity, + offer: &OfferFrame, +) -> Result { + let config = server_config_for( + &state.trust, + state.verifier.clock(), + *state.own_key.read(), + Arc::clone(&state.crls), + identity, + ); + let mut tls = TlsAcceptor::from(config) + .accept(io) + .await + .map_err(|err| ExchangeError::from(classify_io(err)))?; + let leaf = { + let certs = tls + .get_ref() + .1 + .peer_certificates() + .expect("the client auth is mandatory"); + let (Some(leaf), Some(_)) = (certs.first(), certs.get(1)) else { + return Err(ExchangeError::Protocol( + "the peer presented no issuer certificate".into(), + )); + }; + leaf.to_vec() + }; + let peer = DeviceCertificate::parse(&leaf) + .expect("the profile was verified at the handshake") + .identity() + .clone(); + let _ = exchange_hello(&mut tls, state, 0).await?; + // The joiner closes its write side once its lists are sent, so the + // stream's end ends the swap. + loop { + let frame = read_frame(&mut tls) + .await + .map_err(classify_frame) + .map_err(ExchangeError::from)?; + match frame { + None => break, + Some(PeerFrame::List { list, signer }) => { + hand_list(state, list.to_vec(), signer.to_vec()); + } + Some(frame) => { + return Err(ExchangeError::Protocol(format!( + "the joiner sent a {frame:?} frame where its lists end" + ))); + } + } + } + write_frame( + &mut tls, + &PeerFrame::Offer { + ssid: offer.ssid.clone(), + passphrase: offer.wire_passphrase(), + security: offer.security, + port: offer.port, + }, + ) + .await + .map_err(classify_frame) + .map_err(ExchangeError::from)?; + // The joiner's reads may already be done, so the close can fail. + let _ = tls.shutdown().await; + Ok(peer) +} + +/// The joiner's side, the dial's side of a link without the registration. +async fn joiner_exchange( + state: &NodeState, + io: S, + identity: &Identity, +) -> Result<(DeviceIdentity, OfferFrame), ExchangeError> { + let config = client_config_for( + &state.trust, + state.verifier.clock(), + *state.own_key.read(), + Arc::clone(&state.crls), + identity, + ); + let connector = TlsConnector::from(config); + let domain = ServerName::try_from(DIAL_NAME).expect("a valid dial name"); + let mut tls = connector + .connect(domain, io) + .await + .map_err(|err| ExchangeError::from(classify_io(err)))?; + let leaf = { + let certs = tls + .get_ref() + .1 + .peer_certificates() + .expect("the client auth is mandatory"); + let (Some(leaf), Some(_)) = (certs.first(), certs.get(1)) else { + return Err(ExchangeError::Protocol( + "the peer presented no issuer certificate".into(), + )); + }; + leaf.to_vec() + }; + let peer = DeviceCertificate::parse(&leaf) + .expect("the profile was verified at the handshake") + .identity() + .clone(); + let _ = exchange_hello(&mut tls, state, 0).await?; + // The host reads the joiner's lists until this write side closes, so the + // close ends the swap, and the offer is the host's next and last frame. + tls.shutdown().await.map_err(ExchangeError::Io)?; + loop { + let frame = read_frame(&mut tls) + .await + .map_err(classify_frame) + .map_err(ExchangeError::from)?; + match frame { + Some(PeerFrame::List { list, signer }) => { + hand_list(state, list.to_vec(), signer.to_vec()); + } + Some(PeerFrame::Offer { + ssid, + passphrase, + security, + port, + }) => { + return Ok(( + peer, + OfferFrame { + ssid, + passphrase: passphrase.into_bytes(), + security, + port, + }, + )); + } + Some(frame) => { + return Err(ExchangeError::Protocol(format!( + "the host sent a {frame:?} frame where the offer comes" + ))); + } + None => { + return Err(ExchangeError::Protocol( + "the host closed before its offer".into(), + )); + } + } + } +} + +/// Hand a received list to the node, the way a link hands one, so it reaches +/// the client's one intake. +fn hand_list(state: &NodeState, list: Vec, signer: Vec) { + state + .events + .send(PeerEvent::ListReceived { list, signer }) + .ok(); +} diff --git a/crates/connetto-peer/src/exchange_proofs.rs b/crates/connetto-peer/src/exchange_proofs.rs new file mode 100644 index 00000000..3c82825a --- /dev/null +++ b/crates/connetto-peer/src/exchange_proofs.rs @@ -0,0 +1,550 @@ +//! The R76 slice 5 proofs, over chunk streams built on one channel each +//! way, with every certificate minted here. + +use std::sync::Arc; +use std::time::Duration; + +use connetto_core::device_cert::AttestationLevel; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +use crate::error::{ExchangeError, Refusal}; +use crate::exchange::{ChunkStream, EXCHANGE_BOUND, OfferFrame}; +use crate::frame::{PeerFrame, read_frame, write_frame}; +use crate::node::{client_config_for, server_config_for}; +use crate::tests::{Deployment, Peer, events, node, serve, whole_second}; +use crate::{CloseReason, PeerEvent, SystemClock, Trust}; + +const DAY: Duration = Duration::from_hours(24); + +/// Every attestation level, the deployment default. +fn all_levels() -> Vec { + AttestationLevel::ALL.into_iter().collect() +} + +/// Two chunk streams over one channel each way, the two characteristics a +/// GATT transport is, so a write close ends only the peer's reads. +fn chunk_pair(chunk: usize) -> (ChunkStream, ChunkStream) { + let (one_to_two, two_inbound) = mpsc::channel(64); + let (two_to_one, one_inbound) = mpsc::channel(64); + ( + ChunkStream::new(one_inbound, one_to_two, chunk), + ChunkStream::new(two_inbound, two_to_one, chunk), + ) +} + +/// A raw TLS joiner over a chunk stream, through the crate's own client +/// config, so a proof can stop answering after the handshake. +async fn raw_client( + root_der: Vec, + peer: &Peer, + io: ChunkStream, +) -> tokio_rustls::client::TlsStream { + let crls = Arc::new(parking_lot::RwLock::new(Arc::from( + Vec::::new().into_boxed_slice(), + ))); + let config = client_config_for( + &Trust { + roots: vec![root_der], + accepted: all_levels(), + }, + Arc::new(SystemClock), + Some(peer.key_id()), + crls, + &peer.identity(), + ); + let connector = TlsConnector::from(config); + let domain = ServerName::try_from("connetto-peer").expect("a valid dial name"); + connector + .connect(domain, io) + .await + .expect("the handshake completes") +} + +/// A raw TLS host over a chunk stream, through the crate's own server +/// config, so a proof can stop answering after the handshake. +async fn raw_server( + root_der: Vec, + peer: &Peer, + io: ChunkStream, +) -> tokio_rustls::server::TlsStream { + let crls = Arc::new(parking_lot::RwLock::new(Arc::from( + Vec::::new().into_boxed_slice(), + ))); + let config = server_config_for( + &Trust { + roots: vec![root_der], + accepted: all_levels(), + }, + Arc::new(SystemClock), + Some(peer.key_id()), + crls, + &peer.identity(), + ); + TlsAcceptor::from(config) + .accept(io) + .await + .expect("the handshake completes") +} + +/// Wait for one delivered list, failing if ten seconds run out. +async fn await_list(rx: &mut mpsc::UnboundedReceiver) -> PeerEvent { + tokio::time::timeout(Duration::from_secs(10), rx.recv()) + .await + .expect("the list arrives") + .expect("the channel stays open") +} + +/// A trusted pair over a chunk stream of `chunk` bytes gets the offer and +/// swaps the lists either side lacks. +async fn a_trusted_pair(chunk: usize) { + let now = whole_second(); + let deployment = Deployment::new(30, now); + let host_issuer = deployment.add_issuer(now, [1; 16]); + let joiner_issuer = deployment.add_issuer(now, [2; 16]); + let host = deployment.device( + &host_issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let joiner = deployment.device( + &joiner_issuer, + "joiner", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + let (h_tx, mut h_rx) = events(); + let (j_tx, mut j_rx) = events(); + let host_node = node(deployment.root_der(), h_tx); + let joiner_node = node(deployment.root_der(), j_tx); + serve(&host_node, &host); + serve(&joiner_node, &joiner); + + // Each side keeps a list the other lacks, so both lists cross. + let (h_list, h_signer) = deployment.issuer_list(&host_issuer, 1, &[], now); + let (j_list, j_signer) = deployment.issuer_list(&joiner_issuer, 1, &[], now); + host_node.keep_list(h_list.clone(), h_signer.clone()); + joiner_node.keep_list(j_list.clone(), j_signer.clone()); + + let (host_io, joiner_io) = chunk_pair(chunk); + let offer = OfferFrame::new("camp", "opensesame", 1, Some(5678)); + let (host_res, joiner_res) = tokio::join!( + host_node.offer_over(host_io, offer), + joiner_node.fetch_over(joiner_io) + ); + let (peer, received) = joiner_res.expect("the exchange completes"); + assert_eq!(peer, host.identity); + assert_eq!(received.ssid, "camp"); + assert_eq!(received.passphrase(), "opensesame"); + assert_eq!(received.security, 1); + assert_eq!(received.port, Some(5678)); + assert_eq!(host_res.expect("the exchange completes"), joiner.identity); + + // The joiner's list reaches the host's intake, the host's the joiner's. + assert_eq!( + await_list(&mut h_rx).await, + PeerEvent::ListReceived { + list: j_list, + signer: j_signer + } + ); + assert_eq!( + await_list(&mut j_rx).await, + PeerEvent::ListReceived { + list: h_list, + signer: h_signer + } + ); + + host_node.stop(CloseReason::Closed); + joiner_node.stop(CloseReason::Closed); +} + +/// Proof. +#[tokio::test] +async fn a_trusted_pair_gets_the_offer_and_swaps_a_list() { + // The smallest GATT MTU chunk size and a phone-sized one. + for chunk in [20, 514] { + a_trusted_pair(chunk).await; + } +} + +/// A refused exchange between `host` on `host_root` and `joiner` on +/// `joiner_root`, where each side trusts no chain the other presents. +async fn a_refused_exchange( + chunk: usize, + host: &Peer, + host_root: Vec, + joiner: &Peer, + joiner_root: Vec, +) { + let (h_tx, _h_rx) = events(); + let (j_tx, _j_rx) = events(); + let host_node = node(host_root, h_tx); + let joiner_node = node(joiner_root, j_tx); + serve(&host_node, host); + serve(&joiner_node, joiner); + + let (host_io, joiner_io) = chunk_pair(chunk); + let offer = OfferFrame::new("camp", "opensesame", 1, Some(5678)); + let (host_res, joiner_res) = tokio::join!( + host_node.offer_over(host_io, offer), + joiner_node.fetch_over(joiner_io) + ); + // The joiner's own verifier refuses the host's chain before any offer. + assert!(matches!( + joiner_res, + Err(ExchangeError::Refused(Refusal::Untrusted)) + )); + // The host refuses the joiner's chain with its own verifier or the + // joiner's alert, and hands no offer either way. + assert!(matches!( + host_res, + Err(ExchangeError::Refused(_) | ExchangeError::RefusedByPeer(_)) + )); +} + +/// Proof. +#[tokio::test] +async fn a_foreign_root_is_refused_both_ways_with_no_offer() { + let now = whole_second(); + let home = Deployment::new(31, now); + let foreign = Deployment::new(32, now); + let home_issuer = home.add_issuer(now, [1; 16]); + let foreign_issuer = foreign.add_issuer(now, [1; 16]); + let home_host = home.device( + &home_issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let home_joiner = home.device( + &home_issuer, + "joiner", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + let foreign_host = foreign.device( + &foreign_issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let foreign_joiner = foreign.device( + &foreign_issuer, + "joiner", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + // The foreign joiner reaches the home host, and the home joiner the + // foreign host. + a_refused_exchange( + 20, + &home_host, + home.root_der(), + &foreign_joiner, + foreign.root_der(), + ) + .await; + a_refused_exchange( + 514, + &foreign_host, + foreign.root_der(), + &home_joiner, + home.root_der(), + ) + .await; +} + +/// Proof. +#[tokio::test] +async fn an_expired_joiner_gets_no_offer() { + let now = whole_second(); + let deployment = Deployment::new(33, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let host = deployment.device( + &issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + // The joiner's window closed a day ago, and the host's verifier refuses + // it while the joiner's accepts the host's still-valid chain. + let joiner = deployment.device( + &issuer, + "joiner", + now - 2 * DAY, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + let (h_tx, _h_rx) = events(); + let (j_tx, _j_rx) = events(); + let host_node = node(deployment.root_der(), h_tx); + let joiner_node = node(deployment.root_der(), j_tx); + serve(&host_node, &host); + serve(&joiner_node, &joiner); + + let (host_io, joiner_io) = chunk_pair(20); + let offer = OfferFrame::new("camp", "opensesame", 1, Some(5678)); + let (host_res, joiner_res) = tokio::join!( + host_node.offer_over(host_io, offer), + joiner_node.fetch_over(joiner_io) + ); + // The joiner's handshake completes and its first frame read meets the + // host's alert, with no offer anywhere. + assert!(matches!(joiner_res, Err(ExchangeError::RefusedByPeer(_)))); + assert!(matches!( + host_res, + Err(ExchangeError::Refused(Refusal::Expired)) + )); +} + +/// Proof. +#[tokio::test] +async fn a_version_mismatch_is_refused() { + let now = whole_second(); + let deployment = Deployment::new(34, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let host = deployment.device( + &issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let joiner = deployment.device( + &issuer, + "joiner", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + // A joiner speaking version 2 gets no offer, and the host refuses it. + let (h_tx, _h_rx) = events(); + let host_node = node(deployment.root_der(), h_tx); + serve(&host_node, &host); + let (host_io, joiner_io) = chunk_pair(20); + let offer = OfferFrame::new("camp", "opensesame", 1, Some(5678)); + let (host_res, ()) = tokio::join!(host_node.offer_over(host_io, offer), async { + let mut raw = raw_client(deployment.root_der(), &joiner, joiner_io).await; + let hello = PeerFrame::Hello { + version: 2, + dial: 1, + numbers: Vec::new(), + }; + write_frame(&mut raw, &hello) + .await + .expect("the hello writes"); + let _ = read_frame(&mut raw).await; + }); + assert!(matches!( + host_res, + Err(ExchangeError::UnsupportedVersion { their: 2 }) + )); + + // A host speaking version 2 refuses the joiner the same way. + let (j_tx, _j_rx) = events(); + let joiner_node = node(deployment.root_der(), j_tx); + serve(&joiner_node, &joiner); + let (host_io, joiner_io) = chunk_pair(514); + let raw_host = tokio::spawn(async move { + let mut raw = raw_server(deployment.root_der(), &host, host_io).await; + let hello = PeerFrame::Hello { + version: 2, + dial: 1, + numbers: Vec::new(), + }; + write_frame(&mut raw, &hello) + .await + .expect("the hello writes"); + let _ = read_frame(&mut raw).await; + }); + let result = joiner_node.fetch_over(joiner_io).await; + assert!(matches!( + result, + Err(ExchangeError::UnsupportedVersion { their: 2 }) + )); + raw_host.await.expect("the raw host ends"); +} + +/// Proof. +#[tokio::test] +async fn a_silent_host_times_out_at_the_bound() { + let now = whole_second(); + let deployment = Deployment::new(35, now); + let issuer = deployment.add_issuer(now, [1; 16]); + let host = deployment.device( + &issuer, + "host", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let joiner = deployment.device( + &issuer, + "joiner", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + let (j_tx, _j_rx) = events(); + let joiner_node = node(deployment.root_der(), j_tx); + serve(&joiner_node, &joiner); + let (host_io, joiner_io) = chunk_pair(20); + + // A host that completes the handshake and the hello and then stops + // answering, holding the connection open. + let silent = tokio::spawn(async move { + let mut tls = raw_server(deployment.root_der(), &host, host_io).await; + let hello = PeerFrame::Hello { + version: 1, + dial: 0, + numbers: Vec::new(), + }; + write_frame(&mut tls, &hello) + .await + .expect("the hello writes"); + let _ = read_frame(&mut tls).await; + // The joiner's lists end with its write close, then the silence. + while let Ok(Some(_)) = read_frame(&mut tls).await {} + tokio::time::sleep(Duration::from_secs(60)).await; + }); + + tokio::time::pause(); + let started = tokio::time::Instant::now(); + let result = joiner_node.fetch_over(joiner_io).await; + let elapsed = started.elapsed(); + tokio::time::resume(); + silent.abort(); + assert!(matches!(result, Err(ExchangeError::Timeout))); + assert!(elapsed >= EXCHANGE_BOUND); +} + +/// Proof. +#[tokio::test] +async fn a_non_serving_node_refuses() { + let now = whole_second(); + let deployment = Deployment::new(36, now); + + let (h_tx, _h_rx) = events(); + let (j_tx, _j_rx) = events(); + let host_node = node(deployment.root_der(), h_tx); + let joiner_node = node(deployment.root_der(), j_tx); + // Neither node serves an identity. + let (host_io, joiner_io) = chunk_pair(20); + let offer = OfferFrame::new("camp", "opensesame", 1, Some(5678)); + assert!(matches!( + host_node.offer_over(host_io, offer).await, + Err(ExchangeError::NotServing) + )); + assert!(matches!( + joiner_node.fetch_over(joiner_io).await, + Err(ExchangeError::NotServing) + )); +} + +/// The exchange's chunk codec, over plain channels with no duplex behind. +#[tokio::test] +async fn a_chunk_stream_splits_writes_and_rejoins_reads() { + let (outbound, mut outbound_rx) = mpsc::channel(4); + let (inbound_tx, inbound_rx) = mpsc::channel(4); + let mut stream = ChunkStream::new(inbound_rx, outbound, 20); + + // A 100-byte write crosses as five 20-byte chunks. + let data: Vec = (0..100u8).collect(); + stream.write_all(&data).await.expect("the write lands"); + for expected in data.chunks(20) { + let chunk = outbound_rx.recv().await.expect("the chunk arrives"); + assert_eq!(chunk, expected); + } + + // A 10-byte and a 20-byte inbound chunk rejoin into one 30-byte read. + inbound_tx + .send(vec![100, 101, 102, 103, 104, 105, 106, 107, 108, 109]) + .await + .expect("the chunk lands"); + inbound_tx + .send((0..20u8).collect()) + .await + .expect("the chunk lands"); + let mut buf = [0u8; 30]; + stream.read_exact(&mut buf).await.expect("the read lands"); + assert_eq!( + &buf[..10], + &[100, 101, 102, 103, 104, 105, 106, 107, 108, 109] + ); + assert_eq!(&buf[10..], &(0..20u8).collect::>()); + + // A closed inbound side ends the reads. + drop(inbound_tx); + let mut eof = [0u8; 8]; + let count = stream.read(&mut eof).await.expect("eof reads"); + assert_eq!(count, 0); +} + +/// A write into a chunk stream whose reader is gone fails with a broken +/// pipe once the bridge reaches the closed channel, so a stale GATT +/// connection cannot wedge the exchange. +#[tokio::test] +async fn an_empty_chunk_is_skipped_and_never_read_as_the_end() { + let (outbound, _outbound_rx) = mpsc::channel(4); + let (inbound_tx, inbound_rx) = mpsc::channel(4); + let mut stream = ChunkStream::new(inbound_rx, outbound, 20); + inbound_tx.send(Vec::new()).await.expect("the chunk lands"); + inbound_tx.send(vec![7, 8]).await.expect("the chunk lands"); + let mut buf = [0u8; 8]; + let read = stream.read(&mut buf).await.expect("the read lands"); + assert_eq!(&buf[..read], &[7, 8]); +} + +#[tokio::test] +async fn a_chunk_stream_errors_writes_after_the_reader_is_gone() { + let (outbound, outbound_rx) = mpsc::channel(4); + let (_inbound_tx, inbound_rx) = mpsc::channel(4); + let mut stream = ChunkStream::new(inbound_rx, outbound, 16); + // The peer's chunk channel closes. + drop(outbound_rx); + // A full chunk commits and the bridge sends it into the closed channel, + // which ends the bridge. A further write into the dead bridge fails. + stream + .write_all(&[0u8; 16]) + .await + .expect("a full chunk commits"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut failed = false; + while tokio::time::Instant::now() < deadline { + if stream.write_all(&[7u8; 16]).await.is_err() { + failed = true; + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + assert!(failed, "a write into a closed channel fails"); +} diff --git a/crates/connetto-peer/src/fingerprint.rs b/crates/connetto-peer/src/fingerprint.rs index 5a442a4d..9e757bc0 100644 --- a/crates/connetto-peer/src/fingerprint.rs +++ b/crates/connetto-peer/src/fingerprint.rs @@ -32,6 +32,14 @@ impl Fingerprint { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// The first 8 bytes, what the beacon advertises (R76 decision 18). + #[must_use] + pub fn prefix(&self) -> [u8; 8] { + let mut out = [0u8; 8]; + out.copy_from_slice(&self.0[..8]); + out + } } impl fmt::Display for Fingerprint { diff --git a/crates/connetto-peer/src/frame.rs b/crates/connetto-peer/src/frame.rs index b092a5da..8b46e2f1 100644 --- a/crates/connetto-peer/src/frame.rs +++ b/crates/connetto-peer/src/frame.rs @@ -49,6 +49,18 @@ pub enum PeerFrame { /// The leaf's issuer certificate, DER. issuer: ByteBuf, }, + /// The hotspot details the host hands the joiner in the Bluetooth + /// exchange, once both have proved their identity (R76 slice 5). + Offer { + /// The hosted network's name. + ssid: String, + /// The hosted network's passphrase. + passphrase: String, + /// The security the network carries, 0 for WPA2 and 1 for WPA3. + security: u8, + /// The host's peer port, while the host serves. + port: Option, + }, /// A liveness probe. Ping, /// The answer to a liveness probe. diff --git a/crates/connetto-peer/src/lib.rs b/crates/connetto-peer/src/lib.rs index b14702d2..a22401ad 100644 --- a/crates/connetto-peer/src/lib.rs +++ b/crates/connetto-peer/src/lib.rs @@ -24,11 +24,18 @@ mod fingerprint; #[cfg(feature = "discovery")] mod policy; +#[cfg(feature = "bluetooth")] +mod beacon; +#[cfg(feature = "bluetooth")] +mod exchange; + #[cfg(test)] mod tests; #[cfg(all(test, feature = "discovery"))] mod discovery_proofs; +#[cfg(all(test, feature = "bluetooth"))] +mod exchange_proofs; pub use error::{CloseReason, LinkError, Refusal, TrustError}; pub use event::PeerEvent; @@ -40,3 +47,10 @@ pub use node::{Liveness, Node, SocketPrep}; pub use discovery::{Discovery, DiscoveryEvent}; #[cfg(feature = "discovery")] pub use fingerprint::Fingerprint; + +#[cfg(feature = "bluetooth")] +pub use beacon::{Beacon, INBOX_UUID, OUTBOX_UUID, SERVICE_UUID}; +#[cfg(feature = "bluetooth")] +pub use error::ExchangeError; +#[cfg(feature = "bluetooth")] +pub use exchange::{ChunkStream, EXCHANGE_BOUND, OfferFrame}; diff --git a/crates/connetto-peer/src/link.rs b/crates/connetto-peer/src/link.rs index 99a137f8..b845c526 100644 --- a/crates/connetto-peer/src/link.rs +++ b/crates/connetto-peer/src/link.rs @@ -129,7 +129,8 @@ async fn on_frame( } else { CloseReason::Closed }), - Ok(Some(PeerFrame::Hello { .. })) => Some(CloseReason::Protocol), + // A hello or an offer past the exchange is a protocol break. + Ok(Some(PeerFrame::Hello { .. } | PeerFrame::Offer { .. })) => Some(CloseReason::Protocol), } } diff --git a/crates/connetto-peer/src/node.rs b/crates/connetto-peer/src/node.rs index 1cbffe53..6a61a213 100644 --- a/crates/connetto-peer/src/node.rs +++ b/crates/connetto-peer/src/node.rs @@ -14,7 +14,7 @@ use connetto_core::device_cert::{ }; use parking_lot::{Mutex, RwLock}; use rustls::pki_types::{CertificateDer, ServerName}; -use rustls::{ClientConfig, ServerConfig, version}; +use rustls::{AlertDescription, ClientConfig, ServerConfig, version}; use serde_bytes::ByteBuf; use socket2::SockRef; use tokio::io::{AsyncRead, AsyncWrite}; @@ -23,8 +23,12 @@ use tokio::sync::{mpsc, watch}; use tokio_rustls::{TlsAcceptor, TlsConnector}; use tracing::{debug, warn}; +#[cfg(feature = "bluetooth")] +use crate::error::ExchangeError; use crate::error::{CloseReason, LinkError, Refusal, TrustError}; use crate::event::PeerEvent; +#[cfg(feature = "bluetooth")] +use crate::exchange::OfferFrame; use crate::frame::{FrameError, PROTOCOL_VERSION, PeerFrame, read_frame, write_frame}; use crate::identity::{Clock, Identity, Trust}; use crate::signer::{IdentityClientCert, IdentityServerCert}; @@ -48,7 +52,7 @@ pub(crate) const SILENCE_LIMIT: Duration = Duration::from_secs(45); /// The inbound handshakes the listener runs at once, the unauthenticated /// network's bound on its sockets. const MAX_INBOUND_HANDSHAKES: usize = 16; -const DIAL_NAME: &str = "connetto-peer"; +pub(crate) const DIAL_NAME: &str = "connetto-peer"; /// The links' liveness bounds, the ping pace and the silence that takes a /// link down. @@ -82,14 +86,14 @@ pub trait SocketPrep: Send + Sync { /// The node's shared state, behind an `Arc` for its tasks. pub(crate) struct NodeState { - trust: Trust, + pub(crate) trust: Trust, root_key_ids: Vec, pub(crate) verifier: PeerVerifier, - own_key: Arc>>, + pub(crate) own_key: Arc>>, identity: RwLock>, lists: Arc>>, /// The kept CRLs, a snapshot swapped by `keep_list`. - crls: Arc>>, + pub(crate) crls: Arc>>, /// The live links and the peers awaiting their keeper, under one lock so /// a close and a registration never race. pub(crate) links: Mutex>, @@ -444,6 +448,52 @@ impl Node { Ok(peer) } + /// Run the Bluetooth exchange as the host over `io`, handing the + /// joiner `offer` once it proves its identity, and answer the joiner's + /// identity. + /// + /// # Errors + /// + /// [`ExchangeError`] when the node does not serve, the identity proof + /// fails, a frame breaks the protocol, or the exchange runs out of its + /// bound. + #[cfg(feature = "bluetooth")] + pub async fn offer_over( + &self, + io: S, + offer: OfferFrame, + ) -> Result { + let identity = self + .state + .identity + .read() + .clone() + .ok_or(ExchangeError::NotServing)?; + crate::exchange::offer_exchange(&self.state, io, &identity, &offer).await + } + + /// Run the Bluetooth exchange as the joiner over `io`, answering the + /// host's identity and its offer. + /// + /// # Errors + /// + /// [`ExchangeError`] when the node does not serve, the identity proof + /// fails, a frame breaks the protocol, or the exchange runs out of its + /// bound. + #[cfg(feature = "bluetooth")] + pub async fn fetch_over( + &self, + io: S, + ) -> Result<(DeviceIdentity, OfferFrame), ExchangeError> { + let identity = self + .state + .identity + .read() + .clone() + .ok_or(ExchangeError::NotServing)?; + crate::exchange::fetch_exchange(&self.state, io, &identity).await + } + /// Keep `list`, signed by `signer`, and forward it to the links that /// lack it. /// @@ -638,41 +688,89 @@ fn presented(identity: &Identity) -> Vec> { ] } -/// A rustls failure as the dial's typed error, the peer's alert first and -/// the own verifier's typed refusal second. -fn map_rustls(err: rustls::Error) -> LinkError { +/// A handshake or frame failure the dial and the exchange each map to +/// their typed error. +pub(crate) enum Failure { + /// The own verifier refused the peer's chain, with the reason. + Refused(Refusal), + /// The peer's TLS alert refused, with its description. + RefusedByPeer(AlertDescription), + /// A frame broke the protocol, with its message. + Protocol(String), + /// A TLS failure without a typed reason. + Tls(rustls::Error), + /// A plain I/O failure. + Io(io::Error), +} + +impl From for LinkError { + fn from(failure: Failure) -> Self { + match failure { + Failure::Refused(refusal) => LinkError::Refused(refusal), + Failure::RefusedByPeer(alert) => LinkError::RefusedByPeer(alert), + Failure::Protocol(message) => LinkError::Protocol(message), + Failure::Tls(source) => LinkError::Tls(source), + Failure::Io(source) => LinkError::Unreachable(source), + } + } +} + +#[cfg(feature = "bluetooth")] +impl From for ExchangeError { + fn from(failure: Failure) -> Self { + match failure { + Failure::Refused(refusal) => ExchangeError::Refused(refusal), + Failure::RefusedByPeer(alert) => ExchangeError::RefusedByPeer(alert), + Failure::Protocol(message) => ExchangeError::Protocol(message), + Failure::Tls(source) => ExchangeError::Tls(source), + Failure::Io(source) => ExchangeError::Io(source), + } + } +} + +/// A rustls failure as a classification, the peer's alert first and the +/// own verifier's typed refusal second. +pub(crate) fn classify_rustls(err: rustls::Error) -> Failure { if let rustls::Error::AlertReceived(alert) = err { - return LinkError::RefusedByPeer(alert); + return Failure::RefusedByPeer(alert); } if let rustls::Error::InvalidCertificate(rustls::CertificateError::Other(inner)) = &err - && let Some(refusal) = inner.0.downcast_ref::() + && let Some(refusal) = inner.0.downcast_ref::() { - return LinkError::Refused(*refusal); + return Failure::Refused(*refusal); + } + Failure::Tls(err) +} + +/// A connect or handshake failure as a classification, a plain I/O failure +/// as `Io`. +pub(crate) fn classify_io(err: io::Error) -> Failure { + match err.downcast::() { + Ok(err) => classify_rustls(err), + Err(err) => Failure::Io(err), + } +} + +/// A frame failure as a classification, surfacing the peer's TLS alert when +/// it reaches the reader on the first read. +pub(crate) fn classify_frame(err: FrameError) -> Failure { + match err { + FrameError::Io(io_err) => classify_io(io_err), + FrameError::TooLong | FrameError::Malformed => Failure::Protocol(err.to_string()), } - LinkError::Tls(err) } /// A connect or handshake failure as the dial's typed error, a plain I/O /// failure as `Unreachable`. fn map_tls(err: io::Error) -> LinkError { - match err.downcast::() { - Ok(err) => map_rustls(err), - Err(err) => LinkError::Unreachable(err), - } + classify_io(err).into() } /// A frame failure as the dial's typed error, surfacing the peer's TLS /// alert when it reaches the dial on the first read, and a plain I/O /// failure as `Unreachable`. fn map_frame(err: FrameError) -> LinkError { - let message = err.to_string(); - match err { - FrameError::Io(io_err) => match io_err.downcast::() { - Ok(rustls_err) => map_rustls(rustls_err), - Err(io_err) => LinkError::Unreachable(io_err), - }, - _ => LinkError::Protocol(message), - } + classify_frame(err).into() } /// The accept loop, until stopped. @@ -787,7 +885,7 @@ async fn inbound(state: Arc, tcp: TcpStream, identity: Identity) { /// # Errors /// /// [`LinkError`] for a timeout, a broken frame, or a different version. -async fn exchange_hello( +pub(crate) async fn exchange_hello( io: &mut S, state: &NodeState, dial: u64, diff --git a/crates/connetto-peer/src/tests.rs b/crates/connetto-peer/src/tests.rs index e9a2fdcd..3f5b75b0 100644 --- a/crates/connetto-peer/src/tests.rs +++ b/crates/connetto-peer/src/tests.rs @@ -180,7 +180,7 @@ impl Deployment { /// An issuer-signed list `number` revoking `revoked`, dated at `now`. #[expect(clippy::unused_self, reason = "the issuer carries its own signer")] - fn issuer_list( + pub(crate) fn issuer_list( &self, issuer: &Issuer, number: u64, @@ -231,7 +231,7 @@ impl Peer { } /// The key id, what the duplicate rule compares. - fn key_id(&self) -> connetto_core::device_cert::KeyId { + pub(crate) fn key_id(&self) -> connetto_core::device_cert::KeyId { key_id(&self.key) } } @@ -2489,3 +2489,61 @@ async fn a_refusing_prep_refuses_the_dial_as_unreachable() { alice.identity ); } + +/// Reproduction, on the plain link path: two devices from two different +/// issuers of one deployment link while the dialer holds a list signed by +/// its own issuer. The issuers share a subject DN, so webpki treats the +/// kept list as authoritative for the peer's leaf and fails its signature +/// against the peer's issuer key. Failing first: once the verifier +/// disambiguates lists by the issuer's key, not its DN, the link completes. +#[tokio::test] +async fn a_kept_list_from_a_dn_sibling_issuer_does_not_refuse_the_link() { + let now = whole_second(); + let deployment = Deployment::new(1, now); + // Two issuers under one deployment: the same subject DN, different keys + // and serials. + let issuer_a = deployment.add_issuer(now, [1; 16]); + let issuer_b = deployment.add_issuer(now, [2; 16]); + let alice = deployment.device( + &issuer_a, + "alice", + now, + DAY, + [1; 16], + AttestationLevel::Unproven, + ); + let bob = deployment.device( + &issuer_b, + "bob", + now, + DAY, + [2; 16], + AttestationLevel::Unproven, + ); + + // The dialer keeps a list signed by its own issuer, a sibling of the + // peer's issuer under the same root, and the link completes. + let (a_tx, _a_rx) = events(); + let (b_tx, _b_rx) = events(); + let a = node(deployment.root_der(), a_tx); + let b = node(deployment.root_der(), b_tx); + serve(&a, &alice); + let b_addr = serve(&b, &bob); + let (list, signer) = deployment.issuer_list(&issuer_a, 1, &[], now); + a.keep_list(list, signer); + let peer = a.link(b_addr).await.expect("the link completes"); + assert_eq!(peer, bob.identity); + + // Control: the root's list names only the root's DN, so it applies to the + // peer's issuer, not the peer's leaf, and verifies under the root's key. + let (a_tx, _a_rx) = events(); + let (b_tx, _b_rx) = events(); + let a = node(deployment.root_der(), a_tx); + let b = node(deployment.root_der(), b_tx); + serve(&a, &alice); + let b_addr = serve(&b, &bob); + let (list, signer) = deployment.root_list(1, &[], now); + a.keep_list(list, signer); + let peer = a.link(b_addr).await.expect("the link completes"); + assert_eq!(peer, bob.identity); +} diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index cd426202..5c8ca9e3 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -38,6 +38,11 @@ //! server path stays over `adb reverse`, so the joiner reaches the server //! while it is off its usual Wi-Fi. The proof ends with both phones back on //! their usual networks. +//! +//! With `--bluetooth` the main phone hosts a hotspot after its own proof, and +//! the run requires the demo's beacon on the panel and its legacy advertising +//! set and GATT service in the phone's Bluetooth dump (R76 slice 5a). The +//! proof ends with the phone's Bluetooth off and its Wi-Fi on. use std::path::{Path, PathBuf}; use std::time::Duration; @@ -86,6 +91,18 @@ const CONNECT_BUTTONS: [&str; 3] = ["Connect", "Connetti", "Verbinden"]; /// The silence a link gets to end after the phones leave the hotspot, on /// either side. const LINK_END_BOUND: Duration = Duration::from_secs(120); +/// The beacon's service-data UUID (R76), required in the host's dump. +const BEACON_SERVICE_UUID: &str = "a9952637-85d5-4071-9ed8-c28bd7ba670c"; +/// The characteristic the joiner writes to (R76). +const BEACON_INBOX_UUID: &str = "bdd3f20f-a8d0-4003-82db-4f941b4c375b"; +/// The characteristic the joiner reads notifications from (R76). +const BEACON_OUTBOX_UUID: &str = "62ba3a58-c377-47f5-93e1-cd6790bc2e53"; +/// The Bluetooth permissions the beacon asks for (R76). +const BT_PERMISSIONS: [&str; 3] = [ + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_ADVERTISE", +]; #[tokio::main] async fn main() -> Result<()> { @@ -95,6 +112,7 @@ async fn main() -> Result<()> { apk, unlock_pin, peer_serial, + bluetooth, } = cli_arguments()?; let device = Device::pick(serial).await?; let evidence = repo_path(&["target", "android-proof"])?.join(format!( @@ -127,8 +145,9 @@ async fn main() -> Result<()> { let outcome = prove(&device, &apk, &stack, &evidence, unlock).await; // The peer proof runs only on a proved phone, and a failed // proof keeps its own error. - match (peer_serial, outcome) { - (Some(serial), Ok(())) => { + match (bluetooth, peer_serial, outcome) { + (true, _, Ok(())) => bluetooth_proof(&device, &evidence).await, + (false, Some(serial), Ok(())) => { peer_proof( &device, &Device::new(serial), @@ -139,7 +158,7 @@ async fn main() -> Result<()> { ) .await } - (_, outcome) => outcome, + (_, _, outcome) => outcome, } } Err(err) => Err(err), @@ -740,6 +759,202 @@ async fn restore_joiner_networks( } } +/// Turn a phone's Bluetooth off and hold until the platform reports it off, +/// which every run ends with. +async fn bluetooth_off(device: &Device) -> Result<()> { + device + .adb(&["shell", "cmd", "bluetooth_manager", "disable"]) + .await?; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let on = device + .adb(&["shell", "settings", "get", "global", "bluetooth_on"]) + .await?; + if on.trim() == "0" { + return Ok(()); + } + if Instant::now() >= deadline { + bail!( + "{} never turned its Bluetooth off after it was disabled", + device.serial + ); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// Turn a phone's Bluetooth on and hold until the platform reports it on. +async fn bluetooth_on(device: &Device) -> Result<()> { + device + .adb(&["shell", "cmd", "bluetooth_manager", "enable"]) + .await?; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let on = device + .adb(&["shell", "settings", "get", "global", "bluetooth_on"]) + .await?; + if on.trim() == "1" { + return Ok(()); + } + if Instant::now() >= deadline { + bail!( + "{} never turned its Bluetooth on after it was enabled", + device.serial + ); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// The host's beacon line off the page, `advertising` with its prefix, bounded. +async fn read_beacon(app: &mut PageSession) -> Result { + app.wait_for_text("beacon: advertising", Duration::from_secs(60)) + .await + .context("the host's beacon never advertised")?; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let page = app.page_text().await?; + if let Some(line) = page + .lines() + .find(|line| line.starts_with("beacon: advertising")) + { + return Ok(line.trim().to_owned()); + } + if Instant::now() >= deadline { + bail!("the beacon line never carried a prefix, the page shows:\n{page}"); + } + sleep(Duration::from_millis(500)).await; + } +} + +/// The host-side Bluetooth beacon proof (R76 slice 5a). The phone hosts a +/// hotspot from the demo, which turns on its beacon, and the proof reads the +/// beacon's prefix from the panel and requires the demo's advertising set and +/// GATT service in the phone's Bluetooth dump. It ends with Bluetooth off. +async fn bluetooth_proof(host: &Device, evidence: &Path) -> Result<()> { + if !demo_has_device_identity() { + bail!("the bluetooth proof needs a demo build with its device identity"); + } + let result = run_bluetooth_proof(host, evidence).await; + let off = bluetooth_off(host).await; + result.and(off) +} + +/// The walk itself: turn Bluetooth on, grant its permissions, host, read the +/// beacon's prefix, and check the advertising set and the service. +async fn run_bluetooth_proof(host: &Device, evidence: &Path) -> Result<()> { + step("turn Bluetooth on"); + bluetooth_on(host).await?; + + step("grant the Bluetooth permissions"); + for permission in BT_PERMISSIONS { + host.adb(&["shell", "pm", "grant", PACKAGE, permission]) + .await?; + } + host.adb(&[ + "shell", + "pm", + "grant", + PACKAGE, + "android.permission.NEARBY_WIFI_DEVICES", + ]) + .await?; + + step("host a hotspot"); + // Some phones refuse to start an access point while their own station + // sits on a 5 GHz channel, so the host leaves its network, as a host in + // the field has none. + host.adb(&["shell", "svc", "wifi", "disable"]).await?; + let result = host_with_beacon(host, evidence).await; + let _ = host.adb(&["shell", "svc", "wifi", "enable"]).await; + result +} + +/// Host from the demo and check the beacon, with the phone off its network. +async fn host_with_beacon(host: &Device, evidence: &Path) -> Result<()> { + sleep(Duration::from_secs(5)).await; + let mut app = host.app().await?; + app.wait_for_text("device: certified until", Duration::from_secs(60)) + .await?; + app.click("Host a hotspot").await?; + let beacon = read_beacon(&mut app).await?; + eprintln!("{beacon}"); + host.screenshot(evidence, "bt-beacon").await?; + + step("check the advertising set and the service"); + let dump = host.adb(&["shell", "dumpsys", "bluetooth_manager"]).await?; + tokio::fs::write(evidence.join("host-bluetooth-dump.txt"), &dump) + .await + .context("writing the host's Bluetooth dump")?; + check_beacon_dump(&dump)?; + eprintln!( + "the demo advertises a legacy connectable set with 9 bytes of service data and serves its GATT service" + ); + + step("stop the hotspot"); + app.click("Stop the hotspot").await?; + host.screenshot(evidence, "bt-clean").await?; + Ok(()) +} + +/// The demo's advertising set in a `dumpsys bluetooth_manager` dump: legacy, +/// connectable, carrying 9 bytes of service data under the beacon's UUID, +/// beside the started GATT service with both characteristics. The dump masks +/// the service data UUID past its first group. +fn check_beacon_dump(dump: &str) -> Result<()> { + let set = dump + .split_once(&format!(" {PACKAGE}\n")) + .map(|(_, rest)| rest) + .context("the dump lists no advertising set of the demo")?; + let set: Vec<&str> = set + .lines() + .take_while(|line| !line.trim().is_empty()) + .collect(); + let field = |name: &str| { + set.iter() + .find(|line| line.contains(&format!("\u{2514}{name} "))) + .and_then(|line| line.rsplit(':').next()) + .map(str::trim) + }; + if field("Legacy") != Some("true") { + bail!( + "the demo's advertising set is not legacy:\n{}", + set.join("\n") + ); + } + if field("Connectable") != Some("true") { + bail!( + "the demo's advertising set is not connectable:\n{}", + set.join("\n") + ); + } + let masked = format!( + "[{}-xxxx-xxxx-xxxx-xxxxxxxxxxxx, 9]", + &BEACON_SERVICE_UUID[..8] + ); + if !set.iter().any(|line| line.trim() == masked) { + bail!( + "the demo's advertising set carries no 9-byte beacon:\n{}", + set.join("\n") + ); + } + let service = format!("Service {BEACON_SERVICE_UUID}, started true"); + let rest = dump + .split_once(&service) + .map(|(_, rest)| rest) + .context("the dump lists no started beacon service")?; + for characteristic in [BEACON_INBOX_UUID, BEACON_OUTBOX_UUID] { + if !rest + .lines() + .take(4) + .any(|line| line.contains(characteristic)) + { + bail!("the beacon service lacks the characteristic {characteristic}"); + } + } + Ok(()) +} + /// Who answers the platform's unlock prompt. #[derive(Clone, Copy)] struct Unlock<'a> { @@ -842,12 +1057,18 @@ struct Arguments { apk: Option, unlock_pin: Option, peer_serial: Option, + /// The host-side Bluetooth beacon proof. + bluetooth: bool, } fn cli_arguments() -> Result { let mut arguments = Arguments::default(); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { + if arg == "--bluetooth" { + arguments.bluetooth = true; + continue; + } let value = args.next().ok_or_else(|| anyhow!("{arg} needs a value"))?; match arg.as_str() { "--serial" => arguments.serial = Some(value), @@ -1437,6 +1658,53 @@ fn saved_network_id(listing: &str, ssid: &str) -> Option { #[cfg(test)] mod tests { + /// The demo's set and service as an A35 on Android 15 dumps them. + const BEACON_DUMP: &str = " dev.connetto.dioxusdemo + Advertising ID : 3 + 1: + \u{2514}Start time : 10-09 03:05:30 + \u{2514}Advertising: + \u{2514}Legacy : true + \u{2514}Anonymous : false + \u{2514}Connectable : true + \u{2514}Scannable : true + \u{2514}Advertise Data: + \u{2514}Include Device Name : false + \u{2514}Service Data(UUID, length of data) : + [a9952637-xxxx-xxxx-xxxx-xxxxxxxxxxxx, 9] + + + 8: [151] Service a9952637-85d5-4071-9ed8-c28bd7ba670c, started true + 8: [153] Characteristic bdd3f20f-a8d0-4003-82db-4f941b4c375b + 8: [155] Characteristic 62ba3a58-c377-47f5-93e1-cd6790bc2e53 + 8: [156] Descriptor 00002902-0000-1000-8000-00805f9b34fb +"; + + #[test] + fn the_beacon_dump_check_takes_the_demo_set_and_refuses_its_variants() { + assert!(super::check_beacon_dump(BEACON_DUMP).is_ok()); + for (from, to) in [ + ( + "Connectable : true", + "Connectable : false", + ), + ( + "Legacy : true", + "Legacy : false", + ), + ("xxxxxxxxxxxx, 9]", "xxxxxxxxxxxx, 27]"), + ("started true", "started false"), + ("Characteristic 62ba3a58", "Characteristic 00000000"), + (" dev.connetto.dioxusdemo", " com.example.other"), + ] { + let broken = BEACON_DUMP.replace(from, to); + assert!( + super::check_beacon_dump(&broken).is_err(), + "accepted with {to}" + ); + } + } + use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index 8681f115..beb89646 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -671,6 +671,22 @@ struct OfferLines { port: Option, } +/// The host's beacon standing as the panel's line. +#[cfg(feature = "peer")] +fn format_beacon(state: connetto_client::BeaconState) -> String { + match state { + connetto_client::BeaconState::Advertising { prefix } => { + let mut line = String::from("beacon: advertising "); + for byte in prefix { + use std::fmt::Write as _; + let _ = write!(line, "{byte:02x}"); + } + line + } + connetto_client::BeaconState::Off { reason } => format!("beacon: off ({reason})"), + } +} + /// The peer and hotspot panel (R76 slice 4): the device's peer listener, its /// linked peers, the hotspot it hosts with the offer shown as plain lines, /// and the form that joins a hotspot another device hosted. @@ -682,6 +698,10 @@ fn PeerPanel(parts: SessionParts) -> Element { // The listener's port, read again every second, since the listener opens // beside the pump the way enrolment does. let mut peer_port: Signal> = use_signal(|| None); + // The Bluetooth standing and the host's beacon, read on the same pace. + let mut bluetooth_line: Signal = use_signal(|| "bluetooth: unsupported".to_owned()); + let mut beacon_line: Signal = + use_signal(|| "beacon: off (no bluetooth on this system)".to_owned()); { let parts = parts.clone(); use_hook(move || { @@ -692,6 +712,14 @@ fn PeerPanel(parts: SessionParts) -> Element { if *peer_port.peek() != port { peer_port.set(port); } + let state = format!("bluetooth: {}", parts.0.native.bluetooth_state()); + if *bluetooth_line.peek() != state { + bluetooth_line.set(state); + } + let line = format_beacon(parts.0.native.beacon_state()); + if *beacon_line.peek() != line { + beacon_line.set(line); + } } }) }); @@ -778,6 +806,8 @@ fn PeerPanel(parts: SessionParts) -> Element { let join_missing = missing; let leave_parts = parts.clone(); let leave_outcome = outcome; + let bt_parts = parts.clone(); + let bt_outcome = outcome; let me = parts .0 @@ -796,6 +826,8 @@ fn PeerPanel(parts: SessionParts) -> Element { div { style: "font-family: monospace; font-size: 0.85em; color: #333; border: 1px solid #ccc; border-radius: 4px; padding: 8px 12px; margin-bottom: 12px; line-height: 1.5;", p { {peer_line} } + p { {bluetooth_line()} } + p { {beacon_line()} } for line in peers.iter().cloned() { p { {line} } } @@ -815,13 +847,16 @@ fn PeerPanel(parts: SessionParts) -> Element { outcome.set("asking for the hotspot".to_owned()); let result = parts.0.native.host_hotspot().await; match result { - Ok(details) => { + Ok(hosted) => { offer.set(Some(OfferLines { - ssid: details.ssid.clone(), - passphrase: details.passphrase.clone(), - port: details.port, + ssid: hosted.offer.ssid.clone(), + passphrase: hosted.offer.passphrase.clone(), + port: hosted.offer.port, })); - outcome.set("hosting".to_owned()); + outcome.set(match hosted.beacon { + Ok(()) => "hosting".to_owned(), + Err(err) => format!("hosting, the beacon is off: {err}"), + }); } Err(err) => { if let HotspotError::MissingPermission(name) = &err { @@ -834,6 +869,20 @@ fn PeerPanel(parts: SessionParts) -> Element { }, "Host a hotspot" } + button { + onclick: move |_| { + let parts = bt_parts.clone(); + let mut outcome = bt_outcome; + spawn(async move { + outcome.set("turning on bluetooth".to_owned()); + match parts.0.native.enable_bluetooth().await { + Ok(()) => outcome.set("bluetooth is on".to_owned()), + Err(err) => outcome.set(err.to_string()), + } + }); + }, + "Turn on Bluetooth" + } button { onclick: move |_| { let parts = stop_parts.clone(); diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 6f7ad2b8..5b7dffe5 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5291,7 +5291,7 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 1. **Android, Windows and Linux start a hotspot from the application, and Apple devices join.** Android uses `startLocalOnlyHotspot`, Windows a Wi-Fi Direct advertisement in legacy mode, which ordinary devices join as an access point, and Linux NetworkManager's hotspot. macOS has no application API to host (`startIBSSMode` is deprecated since macOS 11), and iPhone and iPad host only through Personal Hotspot switched on in Settings, whose password the user types into the application once so the beacon can hand it out. Android as the only application-started host was rejected, since a group of laptops and iPhones would then need a travel router. 2. **A Bluetooth beacon hands out the hotspot details, with a QR code and text as fallback.** Every machine in the set can scan and connect over Bluetooth from Rust through btleplug, which a desktop without a camera needs. With Bluetooth off, the host shows a `WIFI:` QR code and the same details as text. A QR code and text alone were rejected, since a desktop without a camera would type a long random password after every hotspot restart, and so were prepared shared networks alone. -3. **The beacon is public, and each joining device gets the password through a short exchange in which both sides prove their identity.** The beacon carries the protocol version and the host certificate's fingerprint, as the mDNS TXT record does. A joining device connects to the host over Bluetooth, the two run the same mutual TLS as the Wi-Fi link with the same certificate and revocation-list checks, exchange lists, and only then does the host send `HotspotOffer { ssid, passphrase, security, bssid }`. Apple's Wi-Fi password sharing has the same shape, an identity proof over Bluetooth before the password is sent. A revoked or foreign device is refused. A beacon key the server gives every device was rejected, since a lost device keeps reading beacons until a new key reaches the others online, and so was an unencrypted password in the beacon, which lets any stranger fill and flood the hotspot. +3. **The beacon is public, and each joining device gets the password through a short exchange in which both sides prove their identity.** The beacon carries the protocol version and the host certificate's fingerprint, as the mDNS TXT record does. A joining device connects to the host over Bluetooth, the two run the same mutual TLS as the Wi-Fi link with the same certificate and revocation-list checks, exchange lists, and only then does the host send `HotspotOffer { ssid, passphrase, security, port }`. Apple's Wi-Fi password sharing has the same shape, an identity proof over Bluetooth before the password is sent. A revoked or foreign device is refused. A beacon key the server gives every device was rejected, since a lost device keeps reading beacons until a new key reaches the others online, and so was an unencrypted password in the beacon, which lets any stranger fill and flood the hotspot. 4. **An unattended run on emi and a recorded home run prove it.** On emi, from `main` on a schedule and never from a pull request, one A35's application starts the hotspot and the other A35's application joins it, the runner tapping the system's approval (decision 16), with permissions granted by adb. Before each peer phase closes, the maintainer runs a recorded session at home across every local machine (pippo on Linux, the Mac, the Windows machine, the iPhone, the iPad and the Galaxy M52), with Windows and Linux each hosting once. Manual home runs alone were rejected, and so was emi as a GitHub self-hosted runner, which GitHub advises against for public repositories. ### Decisions, taken with the maintainer on 2026-10-06 @@ -5309,6 +5309,10 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 15. **A device joined to a hotspot binds only its peer sockets to that network** (2026-10-08). Android gives a network requested through a `WifiNetworkSpecifier` to the requesting application alone and routes nothing to it unless the application binds, and an A35 supports no two concurrent Wi-Fi connections, measured, so the joined phone also loses its usual Wi-Fi. The link's dial and listen sockets and the mDNS sockets bound for the joined network go through `Network.bindSocket`, and the server connection keeps the default network, mobile data on an A35. Binding the whole process was rejected, since the server would then be unreachable while joined even with mobile data on. 16. **The emi proof joins the second phone through the application** (2026-10-08). The runner reads the host's offer from its page, types it into the joiner's form, taps the system's approval through uiautomator, and asserts that the joiner reaches the host at the gateway and both report the link, so `requestNetwork`, the socket binding and the gateway probe run on hardware. Joining through the shell alone was rejected, since the application's own join path, the riskiest part, would wait for a human run. 17. **Each system needing foreign code or `unsafe` gets its own small crate, and `connetto-client` owns the rules** (2026-10-08). `connetto-client` holds the hotspot API, the backend trait each system implements, and the tables. Android's backend needs Kotlin compiled into the application, since `LocalOnlyHotspotCallback` and `NetworkCallback` are abstract classes, so `connetto-peer-android` ships the plugin through manganis, and the client drives it by class name over `JavaAccess` (R74 decision 20). Apple's and Windows's backends need `unsafe` and arrive in slice 8 as `connetto-peer-apple` and `connetto-peer-windows`, as App Attest lives in `connetto-app-attest`. Linux's NetworkManager backend is safe Rust over zbus and stays in `connetto-peer`. This narrows decision 5, which placed every platform dependency in `connetto-peer`. One crate for every platform's glue was rejected, since it would allow `unsafe` for every platform at once, and so was the Android plugin inside `connetto-auth-session`, which would fit Android alone. +18. **The beacon is a legacy advertisement carrying the first 8 bytes of the leaf's fingerprint** (2026-10-08). A legacy advertisement is the format every joiner receives, iPhone, iPad and the Mac's CoreBluetooth included, and its 31 bytes hold connetto's 128-bit service UUID, the frame version and an 8-byte prefix of the SHA-256 of the presented leaf beside the flags. The prefix lets a joiner tell nearby hosts apart, and the exchange's TLS proves the full identity. This refines decision 3, which named the whole fingerprint. An extended advertisement with the whole fingerprint was rejected, since Apple's devices and some desktop adapters do not reliably see one. +19. **A joiner scans in the background by default and joins on its own only when the application asks** (2026-10-08). `set_hotspot_scan(bool)`, on by default, scans whenever the device serves and has joined no hotspot and reports `HostNearby` and `HostGone`. `set_hotspot_autojoin(bool)`, off by default, also joins the strongest host it sees through the exchange, one attempt per host until its beacon changes. Both are settable on the builder and at run time, since whether unattended joining fits depends on where and how the application is used. `join_nearby` and `fetch_offer` stay for a manual join. An application-driven scan alone was rejected, since hosts should appear without the user asking, and so was joining on by default, since on Android each join shows the system's approval dialog. +20. **Every joiner's central is btleplug, Android's included, once btleplug's Android `init` resolves its classes through the context class loader** (2026-10-08). Measured between the two A35s inside the demo with btleplug 0.13.4: scanning saw the advertised UUID and service data within a second, and connecting, service discovery, an MTU of 517, writes with response and notifications all worked, once one call in btleplug's `init` looked up `Adapter` through `LoaderContext` instead of raw `FindClass`, which on a thread manganis attached never sees application classes. That fix is upstream as deviceplug/btleplug#495, and until a release carries it the workspace pins `LucaCappelletti94/btleplug` at its commit `5f6b368`, as it pins the keyring forks, and moves to the release once one carries it. `connetto-peer-android` bundles btleplug's Java in its Gradle module with R8 keep rules for `com.nonpolynomial.**` and `io.github.gedgygedgy.**`, sets each calling thread's context class loader to the application's, and hands btleplug the virtual machine under its jni 0.22. A Kotlin GATT client in `connetto-peer-android` was rejected, since it serves Android alone and Apple, Windows and Linux joiners would need a second central, and so was waiting for a btleplug release before building the Android central. deviceplug/btleplug#480, a use-after-free in `FnAdapter` on notifications reported at about 1.1% of sessions, stays open, and the emi runs watch for it. +21. **Every backend reports one Bluetooth readiness and prompts only inside a call the user triggered** (2026-10-08). `BluetoothState::{Ready, Off, NotPermitted, Unsupported}` is read with `bluetooth_state()` and reported as `ClientEvent::BluetoothChanged`. The background scan and the host's beacon never prompt, wait while the state is not `Ready` and resume when it is, and the hotspot runs throughout. `host_hotspot`, `join_nearby`, `fetch_offer` and `enable_bluetooth()` run the platform's strongest action once inside the call, the system dialog on Android, switching the radio on through `Windows.Devices.Radios` or BlueZ's `Powered`, and Core Bluetooth's power alert or the Bluetooth settings pane on Apple, then go on or answer `BluetoothOff` or `BluetoothNotPermitted` saying whether the user declined, was sent to settings or met a block. `set_bluetooth_prompt(false)` leaves that action to the application's own `enable_bluetooth()` call, for an application that explains first. `host_hotspot` answers `Hosted { offer, beacon: Result<(), BluetoothError> }`, so the offer reaches the application for the text and QR fallback while the beacon is down, and an error carrying the offer was rejected, since a started hotspot would read as a failure. No system switches Bluetooth on silently everywhere, and every system can report the state and offer one action inside a user gesture, so this is the shape every backend shares. Prompting from the background scan was rejected, and so was answering a typed error alone, which leaves every application to write its own prompt per platform. ### Design @@ -5318,6 +5322,43 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho - **Revocation lists on the link.** Each side sends its CRL Numbers per issuer as the first frames inside the established TLS session and then any list the other lacks (R74). A live link whose peer the new list revokes closes with a typed reason. - **Discovery networks.** emi's eduroam put the two A35s in separate subnets, measured, so they reach each other by direct connection and never by mDNS. Devices on such a network meet through a hotspot. +- **Beacon and exchange.** While the device both hosts a hotspot and serves its peer link, it advertises the beacon of decision 18 and runs a GATT service under connetto's service UUID, with one characteristic the joiner writes and one it reads notifications from. The exchange runs the link's TLS 1.3 with the same verifier over frames split to the negotiated packet size, swaps `Hello` frames and the lists either side lacks into the one intake, and then the host sends `Offer { ssid, passphrase, security, port }`, the hotspot's details with the peer port, and closes. Every exchange is bounded at 30 seconds, at most 4 run at once on a host, and a fifth connection is refused at once. Android needs `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT` and `BLUETOOTH_SCAN` (with `neverForLocation`), which `connetto-peer-android`'s manifest declares, and Bluetooth `Ready` as decision 21 states it. + +The host's beacon against its state, beside the hotspot tables. + +| Event | Off | Advertising | +| --- | --- | --- | +| The device hosts a hotspot and serves | start the GATT service and the advertisement, become Advertising | not applicable | +| The hotspot stops, or the device stops serving | nothing | stop advertising, close the GATT service and every running exchange, become Off | +| A renewal changes the leaf | nothing | advertise again under the new prefix, running exchanges finish under the chain they began with | +| A joiner connects while fewer than 4 exchanges run | not applicable | run the exchange | +| A joiner connects while 4 exchanges run | not applicable | disconnect it at once | +| The joiner's chain is refused | not applicable | end that exchange with no offer, log it | +| The joiner's handshake and hellos complete | not applicable | hand its lists to the intake, send the offer, end the exchange | +| 30 seconds pass in one exchange | not applicable | end that exchange with no offer | +| Bluetooth leaves `Ready` | nothing | stop advertising, close every running exchange, become Off | +| Bluetooth becomes `Ready` while the device hosts and serves | start the GATT service and the advertisement, become Advertising | not applicable | +| `host_hotspot` called while Bluetooth is not `Ready` | the hotspot starts, then decision 21's action runs once unless the prompt is switched off, and the call answers `Hosted { offer, beacon }` with `beacon` holding `BluetoothOff` or `BluetoothNotPermitted` when Bluetooth stays unavailable, the hotspot left running | not applicable | +| Client closed | nothing | stop advertising, close the service | + +The joiner's scan and exchange against its state. Scanning runs while scan is on, the device serves, and no hotspot is joined, and autojoin implies scan. + +| Event | Idle | Scanning | Exchanging | +| --- | --- | --- | --- | +| Scan or autojoin turned on while the device serves with no joined hotspot | start scanning, become Scanning | nothing | nothing | +| Scan turned off, the device stops serving, or a hotspot is joined | nothing | stop scanning, emit `HostGone` for every reported host, become Idle | finish the exchange, then become Idle | +| A beacon with connetto's UUID is seen | not applicable | a new prefix emits `HostNearby`, a known one refreshes its signal | remember it | +| A host's beacon is unseen for 30 seconds | not applicable | emit `HostGone`, forget it | same | +| Autojoin is on and a host not yet tried is seen | not applicable | pick the strongest untried host, become Exchanging | wait | +| `join_nearby(host)` or `fetch_offer(host)` while Bluetooth is not `Ready` | decision 21's action runs once unless the prompt is switched off, then go on as below or answer `BluetoothOff` or `BluetoothNotPermitted` | same | answer `Busy` | +| `join_nearby(host)` or `fetch_offer(host)` | run the exchange, become Exchanging | stop scanning, run the exchange, become Exchanging | answer `Busy` | +| The exchange yields the offer | not applicable | not applicable | `fetch_offer` answers it, `join_nearby` and autojoin hand it to `join_hotspot`, become Idle while joined | +| The host's chain is refused, the exchange times out, or Bluetooth fails | not applicable | not applicable | answer the typed error, mark the host tried until its beacon changes, become Scanning or Idle | +| The join is declined or times out | not applicable | not applicable | mark the host tried until its beacon changes, become Scanning | +| Bluetooth leaves `Ready` | nothing | stop scanning, emit `HostGone` for every reported host, become Idle | fail the exchange with `BluetoothOff` | +| Bluetooth becomes `Ready` while scan is on, the device serves and no hotspot is joined | start scanning, become Scanning | not applicable | not applicable | +| Client closed | nothing | stop scanning | cancel the exchange | + - **Hotspot.** The application calls `NativeClient::host_hotspot()`, answered by a `HotspotOffer { ssid, passphrase, security, port }` or a `HotspotError` (`Unsupported`, `MissingPermission(name)`, `Incompatible`, `NoChannel`, `Disallowed`, `TimedOut`, `Failed`), and `stop_hotspot()`. A joiner calls `join_hotspot(offer)`, answered by the joined network's gateway or a `JoinError` (`Unsupported`, `MissingPermission(name)`, `Declined`, `TimedOut`, `Failed`), and `leave_hotspot()`. `ClientEvent::HotspotStopped` and `HotspotLeft` report what the system ended. Android needs `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES` (with `neverForLocation`), `ACCESS_WIFI_STATE`, `CHANGE_NETWORK_STATE` and `ACCESS_NETWORK_STATE`, which `connetto-peer-android`'s manifest declares, and the application asks the user for `NEARBY_WIFI_DEVICES` at run time, which the plugin offers to do. A system whose backend has not arrived answers `Unsupported` to every call, Linux, Windows and the Apple systems until slice 8. Every wait is bounded, 30 seconds for a hotspot to start and 60 seconds for a join including the user's approval. The hosting side against its state, beside the link's and discovery's tables. Nothing in it depends on the device's standing (decision 14). @@ -5420,10 +5461,11 @@ One pull request each, in this order, each green and useful on its own (2026-10- 2. **The client's peer link** (R74 step 7's peer half, decision 6). **Built (R76, 2026-10-07)** behind `connetto-client`'s `peer` feature, with decisions 9 and 10 in `connetto-peer`, proven by the enrolment unit tests and by `a_reported_device_is_refused_by_a_peer_that_learned_it_from_a_peer` against the real server. The `peer` feature, the builder setters, the table above against the enrolment task's standing, the events, and received lists through the one intake. Proven in CI against the real server, which carries R74's proof 3, three clients and a server stopped before the reported device dials. 3. **Discovery** (step 1, decisions 12 and 13). **Built (R76, 2026-10-07)** in `connetto-peer`'s `discovery` feature, which `connetto-client`'s `peer` feature turns on. `mdns-sd` under `_connetto-peer._tcp.local`, the TXT record carrying the frame version and the leaf fingerprint only, `PeerFound` and `PeerGone`, autolink under the discovery table, and Android's `MulticastLock`. Proven on loopback in CI. The gateway probe of decision 11 arrives with slice 4's join API, which knows the gateway and the port, and the A35 leg of discovery runs over slice 4's hotspot, since emi's eduroam separates the phones. 4. **Android hosting and joining** (step 3's Android part and step 6's Wi-Fi permissions, decisions 11 and 14 to 17). `startLocalOnlyHotspot` and the joining network request through `connetto-peer-android`'s Kotlin plugin, the hotspot tables, the per-socket binding, the gateway probe, the typed refusals naming a missing permission, and the demo hosting, joining and showing its peers. Proven on emi, one A35 hosting and the other joining through its application, the runner tapping the approval (decision 16). **Built (R76, 2026-10-08)**, the two-phone run green on emi, the Android 15 A35 hosting and the Android 14 one joining, both `chip-proven`, the joiner reaching the host at the gateway. Measured on the way: an A35 refuses to start an access point while its own Wi-Fi sits on eduroam's 5 GHz channel 56, hostapd failing on its beacon on both phones, and starts it once its Wi-Fi is off, so the run turns the host's Wi-Fi off first, as a host in the field has no network. Whether mDNS works on a joined network without binding its sockets, which `mdns-sd` offers no way to do, is not measured yet, since the gateway dial links the two first. -5. **The Bluetooth beacon and exchange on Android** (step 4's Android part and step 6's Bluetooth permissions). `BluetoothGattServer` as host, the joiner over btleplug, rustls over the framed characteristics, and `HotspotOffer` only after both sides proved their identity. Proven on emi between the two A35s. -6. **The text and QR fallback** (step 5). The `WIFI:` payload and its text form, the text path proven on emi and the camera scan left to the home run. -7. **The unattended emi run** (step 7's first half). A schedule on emi running slices 4 to 6 from `main`, never from a pull request, with its evidence kept on emi. -8. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run). Split further when it starts. +5. **The Bluetooth beacon and exchange on Android, without the central** (step 4's Android host part and step 6's Bluetooth permissions, decisions 18, 19 and 21). `BluetoothGattServer` and the legacy advertisement as host, decision 21's readiness on Android, the beacon codec, the framed exchange with rustls over a transport trait, `HotspotOffer` only after both sides proved their identity, and both tables, the joiner's driven through a scanning and connecting backend trait. Proven by the exchange and both machines over in-memory transports, and on emi by one A35 hosting with its beacon in its own Bluetooth dump. **Built (R76, 2026-10-09)** as `connetto-peer`'s `bluetooth` feature (`beacon.rs`, `exchange.rs` with `Node::offer_over` and `Node::fetch_over` over `ChunkStream`) and `connetto-client`'s `bluetooth` module, whose machine runs beside the hotspot machine and takes the central as an option, so a system without one neither scans nor joins and `join_nearby` answers `Unsupported`, which is Android until slice 6. On emi the Android 15 A35 hosted from the demo, its panel showed `beacon: advertising` with the prefix, and `dumpsys bluetooth_manager` listed the demo's set as legacy and connectable with 9 bytes of service data under the beacon's UUID beside the started GATT service with both characteristics. Measured on the way: Android's `adb` offers no LE scan without an application, so the second phone's sighting of the beacon moves to slice 6 with the central, and the platform's dump masks the service data UUID past its first group and never shows the data. +6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug, pinned at the fork's fix commit until a release carries it, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. +7. **The text and QR fallback** (step 5). The `WIFI:` payload and its text form, the text path proven on emi and the camera scan left to the home run. +8. **The unattended emi run** (step 7's first half). A schedule on emi running slices 4 to 7 from `main`, never from a pull request, with its evidence kept on emi. +9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run). Split further when it starts. ### Done when