diff --git a/build.rs b/build.rs index 06ab0d5..a1a5b8f 100644 --- a/build.rs +++ b/build.rs @@ -41,9 +41,18 @@ fn main() { // satisfy libdbus-sys's pkg-config cross-compile requirement, and musl // router targets don't run BlueZ by default anyway. println!("cargo:rustc-check-cfg=cfg(bluer_available)"); + // `ble_available` gates the platform-agnostic BLE transport module (pool, + // discovery, per-peer PSM, the generic `BleTransport`). The module compiles + // on every platform that has — or will have — a concrete `BleIo` backend; + // the backend is selected per-platform (BluerIo on linux-glibc, BluestIo on + // macOS, AndroidIo on Android, else the in-memory MockBleIo fallback). + println!("cargo:rustc-check-cfg=cfg(ble_available)"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); if target_os == "linux" && target_env != "musl" { println!("cargo:rustc-cfg=bluer_available"); } + if matches!(target_os.as_str(), "linux" | "macos" | "android") { + println!("cargo:rustc-cfg=ble_available"); + } } diff --git a/src/transport/ble/mod.rs b/src/transport/ble/mod.rs index 9561dae..e34a105 100644 --- a/src/transport/ble/mod.rs +++ b/src/transport/ble/mod.rs @@ -22,6 +22,7 @@ pub mod addr; pub mod discovery; pub mod io; pub mod pool; +pub mod psm; pub mod stats; use super::{ diff --git a/src/transport/ble/psm.rs b/src/transport/ble/psm.rs new file mode 100644 index 0000000..829dc5a --- /dev/null +++ b/src/transport/ble/psm.rs @@ -0,0 +1,171 @@ +//! Per-peer PSM discovery (BLE v2). +//! +//! On the platforms that matter the L2CAP listener PSM is **OS-assigned**, not +//! chosen by the app (Android `listenUsingInsecureL2capChannel`, macOS +//! `CBPeripheralManager.publishL2CAPChannel`); only BlueZ can bind a fixed one. +//! So rather than relying on the fixed `DEFAULT_PSM` (0x0085), every node +//! **advertises its own listener PSM** as a 16-bit little-endian value in the +//! service-data field keyed on the FIPS service UUID, and every dialer **reads a +//! peer's advertised PSM before `connect()`**. The fixed-PSM assumption was a +//! BlueZ quirk; this makes discovery symmetric across all backends. +//! +//! This module holds the platform-agnostic pieces shared by every `BleIo` +//! backend (`BluerIo`, `BluestIo`, `AndroidIo`): the service-data **codec** and +//! the short-lived **`BleAddr → PSM` map**. The per-backend advertise/scan/dial +//! wiring lives in the backends. See +//! [`docs/reference/ble-wire.md`](../../../../docs/reference/ble-wire.md) and +//! [`docs/design/ble-interop.md`](../../../../docs/design/ble-interop.md). + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::addr::BleAddr; + +/// Encode a listener PSM as the 2-byte little-endian service-data payload +/// advertised under the FIPS service UUID. +/// +/// The legacy advertising PDU caps at ~31 bytes, so a 128-bit UUID + this +/// 2-byte value is the tight, legacy-safe layout (see ble-wire.md). +pub fn encode_psm(psm: u16) -> [u8; 2] { + psm.to_le_bytes() +} + +/// Decode a peer's advertised PSM from its FIPS service-data payload. +/// +/// Returns `None` when fewer than 2 bytes are present — e.g. a legacy, +/// UUID-only advert that carries no PSM, for which the dialer falls back to +/// [`DEFAULT_PSM`](super::DEFAULT_PSM). Any bytes beyond the first two are +/// ignored, so the encoding can grow without breaking older readers. +pub fn decode_psm(data: &[u8]) -> Option { + match data { + [lo, hi, ..] => Some(u16::from_le_bytes([*lo, *hi])), + _ => None, + } +} + +/// Short-lived map of discovered peer addresses to their advertised listener +/// PSM, populated by the scan loop and consulted by `connect()`. +/// +/// Keyed on [`BleAddr`], which **rotates** with MAC randomization, so entries +/// are transient: they are re-learned each scan cycle rather than cached +/// durably. A stale entry merely causes one dial failure and a re-probe on the +/// next scan tick (see "Why MAC randomization is harmless" in ble-interop.md). +#[derive(Debug, Default)] +pub struct PsmMap { + inner: Mutex>, +} + +impl PsmMap { + /// Create an empty map. + pub fn new() -> Self { + Self::default() + } + + /// Record a peer's advertised PSM, learned from a scan advert. A later + /// advert for the same address overwrites the earlier value. + pub fn learn(&self, addr: &BleAddr, psm: u16) { + self.lock().insert(addr.clone(), psm); + } + + /// Look up a peer's learned PSM, if one has been seen this scan cycle. + pub fn lookup(&self, addr: &BleAddr) -> Option { + self.lock().get(addr).copied() + } + + /// Resolve the PSM to dial for `addr`: the learned per-peer PSM if known, + /// otherwise `fallback` (the configured PSM, else the legacy + /// [`DEFAULT_PSM`](super::DEFAULT_PSM)). + pub fn resolve(&self, addr: &BleAddr, fallback: u16) -> u16 { + self.lookup(addr).unwrap_or(fallback) + } + + /// Forget a single learned entry (e.g. after a dial failure). + pub fn forget(&self, addr: &BleAddr) { + self.lock().remove(addr); + } + + /// Drop all learned entries. Called at the start of a scan cycle, since + /// addresses rotate and a dropped PSM only costs a dial-retry. + pub fn clear(&self) { + self.lock().clear(); + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::ble::DEFAULT_PSM; + + fn addr(n: u8) -> BleAddr { + BleAddr { + adapter: "ble0".to_string(), + device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n], + } + } + + #[test] + fn codec_roundtrips_little_endian() { + // 0x0085 -> [0x85, 0x00]; explicit LE byte order matches the wire spec. + assert_eq!(encode_psm(0x0085), [0x85, 0x00]); + assert_eq!(encode_psm(0x1234), [0x34, 0x12]); + for psm in [0u16, 1, 0x0085, 0x0080, 0x00FF, 0x1234, u16::MAX] { + assert_eq!(decode_psm(&encode_psm(psm)), Some(psm)); + } + } + + #[test] + fn decode_rejects_short_payload_but_ignores_trailing() { + assert_eq!(decode_psm(&[]), None); // legacy UUID-only advert + assert_eq!(decode_psm(&[0x85]), None); // truncated + // Forward-compatible: trailing bytes beyond the PSM are ignored. + assert_eq!(decode_psm(&[0x85, 0x00, 0xFF, 0xFF]), Some(0x0085)); + } + + #[test] + fn learn_lookup_and_overwrite() { + let map = PsmMap::new(); + assert_eq!(map.lookup(&addr(1)), None); + + map.learn(&addr(1), 0x0091); + assert_eq!(map.lookup(&addr(1)), Some(0x0091)); + + // A later advert for the same address overwrites. + map.learn(&addr(1), 0x00A0); + assert_eq!(map.lookup(&addr(1)), Some(0x00A0)); + + // Distinct addresses are independent. + map.learn(&addr(2), 0x00B0); + assert_eq!(map.lookup(&addr(1)), Some(0x00A0)); + assert_eq!(map.lookup(&addr(2)), Some(0x00B0)); + } + + #[test] + fn resolve_prefers_learned_then_falls_back() { + let map = PsmMap::new(); + // No learned PSM yet -> legacy default. + assert_eq!(map.resolve(&addr(1), DEFAULT_PSM), DEFAULT_PSM); + + map.learn(&addr(1), 0x0091); + assert_eq!(map.resolve(&addr(1), DEFAULT_PSM), 0x0091); + // A different, unseen address still falls back. + assert_eq!(map.resolve(&addr(9), DEFAULT_PSM), DEFAULT_PSM); + } + + #[test] + fn forget_and_clear_drop_entries() { + let map = PsmMap::new(); + map.learn(&addr(1), 0x0091); + map.learn(&addr(2), 0x0092); + + map.forget(&addr(1)); + assert_eq!(map.lookup(&addr(1)), None); + assert_eq!(map.lookup(&addr(2)), Some(0x0092)); + + map.clear(); + assert_eq!(map.lookup(&addr(2)), None); + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 23fb7bc..3375da7 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -14,10 +14,10 @@ pub mod udp; #[cfg(any(target_os = "linux", target_os = "macos"))] pub mod ethernet; -#[cfg(target_os = "linux")] +#[cfg(ble_available)] pub mod ble; -#[cfg(target_os = "linux")] +#[cfg(ble_available)] use ble::DefaultBleTransport; #[cfg(any(target_os = "linux", target_os = "macos"))] use ethernet::EthernetTransport; @@ -903,7 +903,7 @@ pub enum TransportHandle { /// Nym mixnet transport (via SOCKS5). Nym(NymTransport), /// BLE L2CAP transport. - #[cfg(target_os = "linux")] + #[cfg(ble_available)] Ble(DefaultBleTransport), /// In-process loopback transport (test harness only). #[cfg(test)] @@ -920,7 +920,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.start_async().await, TransportHandle::Tor(t) => t.start_async().await, TransportHandle::Nym(t) => t.start_async().await, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.start_async().await, #[cfg(test)] TransportHandle::Loopback(t) => t.start_async().await, @@ -936,7 +936,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.stop_async().await, TransportHandle::Tor(t) => t.stop_async().await, TransportHandle::Nym(t) => t.stop_async().await, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.stop_async().await, #[cfg(test)] TransportHandle::Loopback(t) => t.stop_async().await, @@ -952,7 +952,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.send_async(addr, data).await, TransportHandle::Tor(t) => t.send_async(addr, data).await, TransportHandle::Nym(t) => t.send_async(addr, data).await, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.send_async(addr, data).await, #[cfg(test)] TransportHandle::Loopback(t) => t.send_async(addr, data).await, @@ -968,7 +968,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(), TransportHandle::Nym(t) => t.transport_id(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.transport_id(), #[cfg(test)] TransportHandle::Loopback(t) => t.transport_id(), @@ -984,7 +984,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.name(), TransportHandle::Tor(t) => t.name(), TransportHandle::Nym(t) => t.name(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.name(), #[cfg(test)] TransportHandle::Loopback(_) => None, @@ -1000,7 +1000,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(), TransportHandle::Nym(t) => t.transport_type(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.transport_type(), #[cfg(test)] TransportHandle::Loopback(t) => t.transport_type(), @@ -1016,7 +1016,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.state(), TransportHandle::Tor(t) => t.state(), TransportHandle::Nym(t) => t.state(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.state(), #[cfg(test)] TransportHandle::Loopback(t) => t.state(), @@ -1032,7 +1032,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(), TransportHandle::Nym(t) => t.mtu(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.mtu(), #[cfg(test)] TransportHandle::Loopback(t) => t.mtu(), @@ -1051,7 +1051,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr), TransportHandle::Nym(t) => t.link_mtu(addr), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.link_mtu(addr), #[cfg(test)] TransportHandle::Loopback(t) => t.link_mtu(addr), @@ -1067,7 +1067,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tor(_) => None, TransportHandle::Nym(_) => None, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(_) => None, #[cfg(test)] TransportHandle::Loopback(_) => None, @@ -1083,7 +1083,7 @@ impl TransportHandle { TransportHandle::Tcp(_) => None, TransportHandle::Tor(_) => None, TransportHandle::Nym(_) => None, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(_) => None, #[cfg(test)] TransportHandle::Loopback(_) => None, @@ -1123,7 +1123,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tor(t) => t.discover(), TransportHandle::Nym(t) => t.discover(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.discover(), #[cfg(test)] TransportHandle::Loopback(t) => t.discover(), @@ -1139,7 +1139,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(), TransportHandle::Nym(t) => t.auto_connect(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.auto_connect(), #[cfg(test)] TransportHandle::Loopback(t) => t.auto_connect(), @@ -1155,7 +1155,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tor(t) => t.accept_connections(), TransportHandle::Nym(t) => t.accept_connections(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.accept_connections(), #[cfg(test)] TransportHandle::Loopback(t) => t.accept_connections(), @@ -1177,7 +1177,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.connect_async(addr).await, TransportHandle::Tor(t) => t.connect_async(addr).await, TransportHandle::Nym(t) => t.connect_async(addr).await, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.connect_async(addr).await, #[cfg(test)] TransportHandle::Loopback(_) => Ok(()), // connectionless @@ -1197,7 +1197,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.connection_state_sync(addr), TransportHandle::Tor(t) => t.connection_state_sync(addr), TransportHandle::Nym(t) => t.connection_state_sync(addr), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.connection_state_sync(addr), #[cfg(test)] TransportHandle::Loopback(_) => ConnectionState::Connected, @@ -1216,7 +1216,7 @@ impl TransportHandle { TransportHandle::Tcp(t) => t.close_connection_async(addr).await, TransportHandle::Tor(t) => t.close_connection_async(addr).await, TransportHandle::Nym(t) => t.close_connection_async(addr).await, - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => t.close_connection_async(addr).await, #[cfg(test)] TransportHandle::Loopback(_) => {} // connectionless no-op @@ -1241,7 +1241,7 @@ impl TransportHandle { TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(), TransportHandle::Nym(_) => TransportCongestion::default(), - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(_) => TransportCongestion::default(), #[cfg(test)] TransportHandle::Loopback(_) => TransportCongestion::default(), @@ -1281,7 +1281,7 @@ impl TransportHandle { TransportHandle::Nym(t) => { serde_json::to_value(t.stats().snapshot()).unwrap_or_default() } - #[cfg(target_os = "linux")] + #[cfg(ble_available)] TransportHandle::Ble(t) => { serde_json::to_value(t.stats().snapshot()).unwrap_or_default() }