diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 248e8cec..a61710d7 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -747,8 +747,15 @@ entries become no-ops. Communicates with BlueZ via D-Bus through the **Advertising and scanning.** When `advertise` is enabled, the transport advertises the FIPS service UUID continuously so that nearby nodes can -discover and connect via L2CAP. When `scan` is enabled, the transport -continuously scans for other FIPS nodes' advertisements. Discovered +discover and connect via L2CAP, plus the L2CAP PSM its listener actually +bound, as a service-data structure (see `src/transport/ble/psm.rs` for the +wire layout and why platforms with OS-assigned PSMs need it). The +advertisement carries no device name — alongside the PSM a name no longer +fits the 31-byte legacy PDU, so the node shows up in generic Bluetooth +scanners as an unnamed device with the FIPS UUID. When `scan` is enabled, +the transport continuously scans for other FIPS nodes' advertisements and +learns each peer's advertised PSM; a peer that advertises none is dialled +at the configured `psm`. Discovered peers are probed immediately (L2CAP connect + pubkey exchange) with a cooldown (`probe_cooldown_secs`) to prevent rapid re-probing of the same address. If two nodes probe each other at the same time (cross-probe), diff --git a/src/config/transport.rs b/src/config/transport.rs index dcb00459..6bc3d380 100644 --- a/src/config/transport.rs +++ b/src/config/transport.rs @@ -711,6 +711,12 @@ pub struct BleConfig { pub adapter: Option, /// L2CAP PSM for FIPS connections. Default: 0x0085 (133). + /// + /// This is the PSM to request for this node's listener, and the PSM to + /// dial a peer at when that peer advertises none. It is not always the + /// PSM finally used: platforms that assign listener PSMs themselves + /// report back what they bound and advertise that, and a peer that + /// advertises its own PSM is dialled there instead. #[serde(default, skip_serializing_if = "Option::is_none")] pub psm: Option, diff --git a/src/node/mod.rs b/src/node/mod.rs index 43641fa1..ab0c30f9 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1164,7 +1164,7 @@ impl Node { let transport_id = self.allocate_transport_id(); let adapter = ble_config.adapter().to_string(); let mtu = ble_config.mtu(); - match crate::transport::ble::io::BluerIo::new(&adapter, mtu).await { + match crate::transport::ble::io_linux::BluerIo::new(&adapter, mtu).await { Ok(io) => { let mut ble = crate::transport::ble::BleTransport::new( transport_id, diff --git a/src/transport/ble/io.rs b/src/transport/ble/io.rs index 6b053492..c7d60408 100644 --- a/src/transport/ble/io.rs +++ b/src/transport/ble/io.rs @@ -1,8 +1,9 @@ //! BLE I/O abstraction layer. //! -//! Defines the `BleIo` trait that separates transport logic from the -//! BlueZ/bluer stack. `BluerIo` (behind `cfg(bluer_available)`) provides -//! the real implementation; `MockBleIo` provides an in-memory test double. +//! Defines the `BleIo` seam that separates transport logic from any one +//! radio stack, and the in-memory `MockBleIo` test double. Everything in +//! this file is platform-neutral; each concrete backend lives beside it in +//! its own `io_.rs` — [`super::io_linux`] for BlueZ. use crate::transport::TransportError; @@ -58,12 +59,53 @@ pub trait BleAcceptor: Send { ) -> impl std::future::Future> + Send; } +/// One advertisement observed by a scanner. +/// +/// Carries what the backend could read from the advert, not what it wishes +/// were there: a backend that cannot surface a field reports `None` for it, +/// the way `TransportHandle::local_addr` already does for transports that +/// have no address to give. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScanAdvert { + /// The advertiser's link address. + pub addr: BleAddr, + /// The L2CAP listener PSM the peer advertised, if it advertised one. + /// + /// `None` for a legacy UUID-only advertiser, and for a backend that + /// cannot read advertised service data. The dialer then falls back to the + /// configured PSM. See [`super::psm`] for the wire layout. + pub psm: Option, + /// Received signal strength in dBm, if the backend reports it. + pub rssi: Option, +} + +impl ScanAdvert { + /// An advert carrying nothing but a link address — what a legacy + /// UUID-only advertiser produces. + pub fn new(addr: BleAddr) -> Self { + Self { + addr, + psm: None, + rssi: None, + } + } + + /// An advert carrying a listener PSM. + pub fn with_psm(addr: BleAddr, psm: u16) -> Self { + Self { + addr, + psm: Some(psm), + rssi: None, + } + } +} + /// A scanner that yields discovered BLE devices advertising the FIPS UUID. pub trait BleScanner: Send { - /// Wait for the next discovered device. + /// Wait for the next observed advertisement. /// /// Returns `None` when scanning is stopped. - fn next(&mut self) -> impl std::future::Future> + Send; + fn next(&mut self) -> impl std::future::Future> + Send; } /// Core BLE I/O operations. @@ -79,11 +121,19 @@ pub trait BleIo: Send + Sync + 'static { /// The concrete scanner type. type Scanner: BleScanner + 'static; - /// Start listening for inbound L2CAP connections on the given PSM. + /// Start listening for inbound L2CAP connections, and report the PSM + /// actually bound. + /// + /// `psm` is the PSM to request. Backends that let an application choose + /// one (BlueZ) bind it and report it back unchanged. Backends whose + /// platform assigns the PSM (Android, macOS) ignore the request and + /// report what the OS gave them — which is why this returns a value + /// rather than being assumed equal to the argument. The reported PSM is + /// what gets advertised. fn listen( &self, psm: u16, - ) -> impl std::future::Future> + Send; + ) -> impl std::future::Future> + Send; /// Connect to a remote BLE device on the given PSM. fn connect( @@ -92,9 +142,15 @@ pub trait BleIo: Send + Sync + 'static { psm: u16, ) -> impl std::future::Future> + Send; - /// Start advertising the FIPS service UUID. + /// Start advertising the FIPS service UUID, and the listener PSM. + /// + /// `psm` is the PSM this node's listener is bound to; see [`super::psm`] + /// for the wire layout it should be advertised in. A backend that cannot + /// put it in its advert ignores the argument, and peers dial it at their + /// configured PSM as before. fn start_advertising( &self, + psm: u16, ) -> impl std::future::Future> + Send; /// Stop advertising. @@ -114,390 +170,6 @@ pub trait BleIo: Send + Sync + 'static { fn adapter_name(&self) -> &str; } -// ============================================================================ -// BluerIo — Production BLE I/O via BlueZ D-Bus -// ============================================================================ - -#[cfg(bluer_available)] -mod bluer_impl { - use super::*; - use crate::transport::TransportError; - - use bluer::l2cap::{SeqPacket, SeqPacketListener, Socket, SocketAddr}; - use bluer::{ - AdapterEvent, AddressType, DiscoveryFilter, DiscoveryTransport, adv::Advertisement, - }; - use futures::StreamExt; - use std::collections::{BTreeSet, HashSet}; - use std::pin::Pin; - use tokio::sync::Mutex; - use tracing::{debug, trace}; - - /// FIPS BLE service UUID. - /// - /// Derived from SHA-256("FIPS: welcome to cryptoanarchy") with UUID v4 - /// version/variant bits applied. - pub const FIPS_SERVICE_UUID: bluer::Uuid = - bluer::Uuid::from_u128(0x9c90_b790_2cc5_42c0_9f87_c9cc_4064_8f4c); - - /// Map a bluer error to a TransportError. - fn map_err(context: &str, e: bluer::Error) -> TransportError { - TransportError::Io(std::io::Error::other(format!("{}: {}", context, e))) - } - - /// Map a std::io::Error to a TransportError. - fn map_io_err(context: &str, e: std::io::Error) -> TransportError { - TransportError::Io(std::io::Error::new(e.kind(), format!("{}: {}", context, e))) - } - - // ---------------------------------------------------------------- - // BluerStream - // ---------------------------------------------------------------- - - /// BLE stream wrapping a bluer L2CAP SeqPacket connection. - pub struct BluerStream { - conn: SeqPacket, - remote: BleAddr, - send_mtu: u16, - recv_mtu: u16, - } - - impl BluerStream { - /// Construct from a connected SeqPacket, querying MTU values. - pub fn new(conn: SeqPacket, remote: BleAddr) -> Result { - let send_mtu = conn.send_mtu().map_err(|e| map_io_err("send_mtu", e))? as u16; - let recv_mtu = conn.recv_mtu().map_err(|e| map_io_err("recv_mtu", e))? as u16; - - // Log negotiated PHY for diagnostics (2M vs 1M) - match conn.as_ref().phy() { - Ok(phy) => { - debug!(addr = %remote, phy, send_mtu, recv_mtu, "BLE connection established") - } - Err(_) => { - debug!(addr = %remote, send_mtu, recv_mtu, "BLE connection established (PHY query unsupported)") - } - } - - Ok(Self { - conn, - remote, - send_mtu, - recv_mtu, - }) - } - } - - impl BleStream for BluerStream { - async fn send(&self, data: &[u8]) -> Result<(), TransportError> { - self.conn - .send(data) - .await - .map(|_| ()) - .map_err(|e| TransportError::SendFailed(format!("{}", e))) - } - - async fn recv(&self, buf: &mut [u8]) -> Result { - self.conn - .recv(buf) - .await - .map_err(|e| TransportError::RecvFailed(format!("{}", e))) - } - - fn send_mtu(&self) -> u16 { - self.send_mtu - } - - fn recv_mtu(&self) -> u16 { - self.recv_mtu - } - - fn remote_addr(&self) -> &BleAddr { - &self.remote - } - } - - // ---------------------------------------------------------------- - // BluerAcceptor - // ---------------------------------------------------------------- - - /// Acceptor wrapping a bluer L2CAP SeqPacketListener. - pub struct BluerAcceptor { - listener: SeqPacketListener, - adapter_name: String, - } - - impl BleAcceptor for BluerAcceptor { - type Stream = BluerStream; - - async fn accept(&mut self) -> Result { - let (conn, peer_sa) = self - .listener - .accept() - .await - .map_err(|e| map_io_err("accept", e))?; - - let remote = BleAddr::from_bluer(peer_sa.addr, &self.adapter_name); - BluerStream::new(conn, remote) - } - } - - // ---------------------------------------------------------------- - // BluerScanner - // ---------------------------------------------------------------- - - /// Scanner wrapping a bluer discovery event stream. - pub struct BluerScanner { - events: Pin + Send>>, - adapter: bluer::Adapter, - adapter_name: String, - } - - impl BleScanner for BluerScanner { - async fn next(&mut self) -> Option { - loop { - match self.events.next().await { - Some(AdapterEvent::DeviceAdded(addr)) => { - // Check if device advertises FIPS UUID - if let Ok(device) = self.adapter.device(addr) { - match device.uuids().await { - Ok(Some(uuids)) if uuids.contains(&FIPS_SERVICE_UUID) => { - let ble_addr = BleAddr::from_bluer(addr, &self.adapter_name); - debug!(addr = %ble_addr, "BLE scanner: FIPS peer found"); - return Some(ble_addr); - } - Ok(_) => { - trace!(addr = %addr, "BLE scanner: device without FIPS UUID"); - } - Err(e) => { - trace!(addr = %addr, error = %e, "BLE scanner: failed to read UUIDs"); - } - } - } - } - Some(_) => continue, - None => return None, - } - } - } - } - - // ---------------------------------------------------------------- - // BluerIo - // ---------------------------------------------------------------- - - /// Production BLE I/O implementation via BlueZ D-Bus (bluer crate). - pub struct BluerIo { - #[allow(dead_code)] // Session must be kept alive for the adapter. - session: bluer::Session, - adapter: bluer::Adapter, - adapter_name: String, - adv_handle: Mutex>, - mtu: u16, - } - - impl BluerIo { - /// Create a new BluerIo for the given adapter. - /// - /// Connects to BlueZ via D-Bus and powers on the adapter. - pub async fn new(adapter_name: &str, mtu: u16) -> Result { - let session = bluer::Session::new() - .await - .map_err(|e| map_err("Session::new", e))?; - - let adapter = if adapter_name == "default" { - session - .default_adapter() - .await - .map_err(|e| map_err("default_adapter", e))? - } else { - session - .adapter(adapter_name) - .map_err(|e| map_err("adapter", e))? - }; - - adapter - .set_powered(true) - .await - .map_err(|e| map_err("set_powered", e))?; - - let name = adapter.name().to_string(); - debug!(adapter = %name, "BluerIo initialized"); - - Ok(Self { - session, - adapter, - adapter_name: name, - adv_handle: Mutex::new(None), - mtu, - }) - } - } - - impl BleIo for BluerIo { - type Stream = BluerStream; - type Acceptor = BluerAcceptor; - type Scanner = BluerScanner; - - async fn listen(&self, psm: u16) -> Result { - let local_addr = self - .adapter - .address() - .await - .map_err(|e| map_err("address", e))?; - - let sa = SocketAddr::new(local_addr, AddressType::LePublic, psm); - let listener = SeqPacketListener::bind(sa) - .await - .map_err(|e| map_io_err("bind", e))?; - - // Request high MTU for accepted connections - listener - .as_ref() - .set_recv_mtu(self.mtu) - .map_err(|e| map_io_err("set_recv_mtu", e))?; - - // Prevent sniff mode to reduce latency during data transfer - if let Err(e) = listener.as_ref().set_power_forced_active(true) { - debug!(error = %e, "BLE listener: set_power_forced_active not supported"); - } - - debug!(psm, mtu = self.mtu, "BLE listener bound"); - - Ok(BluerAcceptor { - listener, - adapter_name: self.adapter_name.clone(), - }) - } - - async fn connect(&self, addr: &BleAddr, psm: u16) -> Result { - let target_sa = addr.to_socket_addr(psm); - - let socket = Socket::::new_seq_packet() - .map_err(|e| map_io_err("new_seq_packet", e))?; - socket - .bind(SocketAddr::any_le()) - .map_err(|e| map_io_err("bind", e))?; - socket - .set_recv_mtu(self.mtu) - .map_err(|e| map_io_err("set_recv_mtu", e))?; - - // Prevent sniff mode to reduce latency during data transfer - if let Err(e) = socket.set_power_forced_active(true) { - debug!(error = %e, "BLE connect: set_power_forced_active not supported"); - } - - let conn = socket - .connect(target_sa) - .await - .map_err(|e| map_io_err("connect", e))?; - - let remote = addr.clone(); - BluerStream::new(conn, remote) - } - - async fn start_advertising(&self) -> Result<(), TransportError> { - let adv = Advertisement { - advertisement_type: bluer::adv::Type::Peripheral, - service_uuids: { - let mut s = BTreeSet::new(); - s.insert(FIPS_SERVICE_UUID); - s - }, - local_name: Some("fips".to_string()), - min_interval: Some(std::time::Duration::from_millis(400)), - max_interval: Some(std::time::Duration::from_millis(600)), - ..Default::default() - }; - - let handle = self - .adapter - .advertise(adv) - .await - .map_err(|e| map_err("advertise", e))?; - - *self.adv_handle.lock().await = Some(handle); - debug!("BLE advertising started"); - Ok(()) - } - - async fn stop_advertising(&self) -> Result<(), TransportError> { - let _ = self.adv_handle.lock().await.take(); - debug!("BLE advertising stopped"); - Ok(()) - } - - async fn start_scanning(&self) -> Result { - // Clear cached devices so BlueZ fires DeviceAdded for every - // advertisement. Without this, already-known devices only - // produce PropertyChanged events (which bluer doesn't expose - // at the device level), causing the scanner to miss peers - // after a daemon restart. - if let Ok(cached) = self.adapter.device_addresses().await { - let count = cached.len(); - for addr in cached { - let _ = self.adapter.remove_device(addr).await; - } - if count > 0 { - debug!(count, "BLE scanner: cleared cached devices"); - } - } - - // Set discovery filter for LE transport with FIPS UUID - let filter = DiscoveryFilter { - transport: DiscoveryTransport::Le, - uuids: { - let mut s = HashSet::new(); - s.insert(FIPS_SERVICE_UUID); - s - }, - ..Default::default() - }; - - self.adapter - .set_discovery_filter(filter) - .await - .map_err(|e| map_err("set_discovery_filter", e))?; - - let events = self - .adapter - .discover_devices() - .await - .map_err(|e| map_err("discover_devices", e))?; - - debug!("BLE scanning started"); - - Ok(BluerScanner { - events: Box::pin(events), - adapter: self.adapter.clone(), - adapter_name: self.adapter_name.clone(), - }) - } - - fn local_addr(&self) -> Result { - // Use futures::executor::block_on since this is a sync method - // but needs an async call. The adapter address is cached so - // the D-Bus call is fast. - let addr = futures::executor::block_on(self.adapter.address()) - .map_err(|e| map_err("address", e))?; - Ok(BleAddr::from_bluer(addr, &self.adapter_name)) - } - - fn adapter_name(&self) -> &str { - &self.adapter_name - } - } - - // Compile-time assertion that BluerIo satisfies Send + Sync. - #[allow(dead_code)] - fn _assert_bluer_io_send_sync() { - fn require() {} - require::(); - } -} - -#[cfg(bluer_available)] -pub use bluer_impl::{BluerAcceptor, BluerIo, BluerScanner, BluerStream, FIPS_SERVICE_UUID}; - // ============================================================================ // Mock BLE I/O (for testing without hardware) // ============================================================================ @@ -583,13 +255,13 @@ impl BleAcceptor for MockBleAcceptor { } } -/// Mock BLE scanner backed by a channel of discovered addresses. +/// Mock BLE scanner backed by a channel of observed adverts. pub struct MockBleScanner { - rx: tokio::sync::mpsc::Receiver, + rx: tokio::sync::mpsc::Receiver, } impl BleScanner for MockBleScanner { - async fn next(&mut self) -> Option { + async fn next(&mut self) -> Option { self.rx.recv().await } } @@ -607,9 +279,15 @@ pub struct MockBleIo { local_addr: BleAddr, accept_tx: tokio::sync::mpsc::Sender, accept_rx: std::sync::Mutex>>, - scan_tx: tokio::sync::mpsc::Sender, - scan_rx: std::sync::Mutex>>, + scan_tx: tokio::sync::mpsc::Sender, + scan_rx: std::sync::Mutex>>, connect_handler: std::sync::Mutex>, + /// PSM `listen` reports back, overriding the requested one. + /// + /// Simulates a platform that assigns the PSM itself. + bound_psm: std::sync::Mutex>, + /// PSM most recently passed to `start_advertising`. + advertised_psm: std::sync::Mutex>, } impl MockBleIo { @@ -625,6 +303,8 @@ impl MockBleIo { scan_tx, scan_rx: std::sync::Mutex::new(Some(scan_rx)), connect_handler: std::sync::Mutex::new(None), + bound_psm: std::sync::Mutex::new(None), + advertised_psm: std::sync::Mutex::new(None), } } @@ -633,9 +313,29 @@ impl MockBleIo { let _ = self.accept_tx.send(stream).await; } - /// Inject a scan result (simulates discovering a remote device). + /// Inject a scan result (simulates discovering a legacy UUID-only + /// advertiser, which carries no PSM). pub async fn inject_scan_result(&self, addr: BleAddr) { - let _ = self.scan_tx.send(addr).await; + self.inject_scan_advert(ScanAdvert::new(addr)).await; + } + + /// Inject an observed advertisement verbatim. + pub async fn inject_scan_advert(&self, advert: ScanAdvert) { + let _ = self.scan_tx.send(advert).await; + } + + /// Make `listen` report a PSM other than the one requested, the way a + /// platform that assigns PSMs itself would. + pub fn set_bound_psm(&self, psm: u16) { + *self.bound_psm.lock().unwrap_or_else(|e| e.into_inner()) = Some(psm); + } + + /// The PSM most recently handed to `start_advertising`. + pub fn advertised_psm(&self) -> Option { + *self + .advertised_psm + .lock() + .unwrap_or_else(|e| e.into_inner()) } /// Set a handler for outbound connect calls. @@ -655,14 +355,19 @@ impl BleIo for MockBleIo { type Acceptor = MockBleAcceptor; type Scanner = MockBleScanner; - async fn listen(&self, _psm: u16) -> Result { + async fn listen(&self, psm: u16) -> Result<(Self::Acceptor, u16), TransportError> { let rx = self .accept_rx .lock() .unwrap() .take() .ok_or_else(|| TransportError::NotSupported("acceptor already taken".into()))?; - Ok(MockBleAcceptor { rx }) + let bound = self + .bound_psm + .lock() + .unwrap_or_else(|e| e.into_inner()) + .unwrap_or(psm); + Ok((MockBleAcceptor { rx }, bound)) } async fn connect(&self, addr: &BleAddr, psm: u16) -> Result { @@ -676,7 +381,11 @@ impl BleIo for MockBleIo { } } - async fn start_advertising(&self) -> Result<(), TransportError> { + async fn start_advertising(&self, psm: u16) -> Result<(), TransportError> { + *self + .advertised_psm + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(psm); Ok(()) } @@ -751,7 +460,8 @@ mod tests { #[tokio::test] async fn test_mock_io_listen_accept() { let io = MockBleIo::new("hci0", test_addr(1)); - let mut acceptor = io.listen(0x0085).await.unwrap(); + let (mut acceptor, bound) = io.listen(0x0085).await.unwrap(); + assert_eq!(bound, 0x0085, "mock binds what it is asked for by default"); let (stream_a, _stream_b) = MockBleStream::pair(test_addr(1), test_addr(2), 2048); io.inject_inbound(stream_a).await; @@ -789,8 +499,8 @@ mod tests { io.inject_scan_result(test_addr(2)).await; io.inject_scan_result(test_addr(3)).await; - assert_eq!(scanner.next().await, Some(test_addr(2))); - assert_eq!(scanner.next().await, Some(test_addr(3))); + assert_eq!(scanner.next().await, Some(ScanAdvert::new(test_addr(2)))); + assert_eq!(scanner.next().await, Some(ScanAdvert::new(test_addr(3)))); } #[tokio::test] @@ -803,10 +513,33 @@ mod tests { #[tokio::test] async fn test_mock_io_advertising_noop() { let io = MockBleIo::new("hci0", test_addr(1)); - io.start_advertising().await.unwrap(); + io.start_advertising(0x0085).await.unwrap(); + assert_eq!(io.advertised_psm(), Some(0x0085)); io.stop_advertising().await.unwrap(); } + /// A backend whose platform assigns the PSM reports back something other + /// than what was requested — the case the return value exists for. + #[tokio::test] + async fn test_mock_io_listen_reports_an_os_assigned_psm() { + let io = MockBleIo::new("hci0", test_addr(1)); + io.set_bound_psm(0x00C1); + let (_acceptor, bound) = io.listen(0x0085).await.unwrap(); + assert_eq!(bound, 0x00C1); + } + + #[tokio::test] + async fn test_mock_io_scan_advert_carries_a_psm() { + let io = MockBleIo::new("hci0", test_addr(1)); + let mut scanner = io.start_scanning().await.unwrap(); + io.inject_scan_advert(ScanAdvert::with_psm(test_addr(2), 0x00C1)) + .await; + let advert = scanner.next().await.unwrap(); + assert_eq!(advert.addr, test_addr(2)); + assert_eq!(advert.psm, Some(0x00C1)); + assert_eq!(advert.rssi, None); + } + #[tokio::test] async fn test_mock_io_listen_twice_fails() { let io = MockBleIo::new("hci0", test_addr(1)); diff --git a/src/transport/ble/io_linux.rs b/src/transport/ble/io_linux.rs new file mode 100644 index 00000000..60d7605e --- /dev/null +++ b/src/transport/ble/io_linux.rs @@ -0,0 +1,456 @@ +//! BlueZ backend for the BLE transport. +//! +//! The Linux implementation of the [`BleIo`](super::io::BleIo) seam, over the +//! `bluer` crate's D-Bus binding to BlueZ. Everything platform-neutral lives +//! in [`super::io`] and the modules beside it; this file holds only what +//! speaks to BlueZ. + +use super::io::*; +use crate::transport::TransportError; + +use bluer::l2cap::{SeqPacket, SeqPacketListener, Socket, SocketAddr}; +use bluer::{AdapterEvent, AddressType, DiscoveryFilter, DiscoveryTransport, adv::Advertisement}; +use futures::StreamExt; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::pin::Pin; +use tokio::sync::Mutex; +use tracing::{debug, trace}; + +use super::addr::BleAddr; +use super::psm; + +/// FIPS BLE service UUID. +/// +/// Derived from SHA-256("FIPS: welcome to cryptoanarchy") with UUID v4 +/// version/variant bits applied. +pub const FIPS_SERVICE_UUID: bluer::Uuid = + bluer::Uuid::from_u128(0x9c90_b790_2cc5_42c0_9f87_c9cc_4064_8f4c); + +/// The PSM service-data key as a whole UUID. +/// +/// BlueZ speaks in full UUIDs, so [`psm::PSM_SERVICE_DATA_UUID16`] is +/// expanded through the Bluetooth base UUID +/// (`00009C90-0000-1000-8000-00805F9B34FB`). The controller emits it +/// back on the air as the 16-bit Service Data AD structure the wire +/// layout in [`psm`] specifies. +pub const PSM_SERVICE_DATA_UUID: bluer::Uuid = bluer::Uuid::from_u128( + ((psm::PSM_SERVICE_DATA_UUID16 as u128) << 96) | 0x0000_0000_0000_1000_8000_0080_5F9B_34FB, +); + +/// Map a bluer error to a TransportError. +fn map_err(context: &str, e: bluer::Error) -> TransportError { + TransportError::Io(std::io::Error::other(format!("{}: {}", context, e))) +} + +/// Map a std::io::Error to a TransportError. +fn map_io_err(context: &str, e: std::io::Error) -> TransportError { + TransportError::Io(std::io::Error::new(e.kind(), format!("{}: {}", context, e))) +} + +// ---------------------------------------------------------------- +// BluerStream +// ---------------------------------------------------------------- + +/// BLE stream wrapping a bluer L2CAP SeqPacket connection. +pub struct BluerStream { + conn: SeqPacket, + remote: BleAddr, + send_mtu: u16, + recv_mtu: u16, +} + +impl BluerStream { + /// Construct from a connected SeqPacket, querying MTU values. + pub fn new(conn: SeqPacket, remote: BleAddr) -> Result { + let send_mtu = conn.send_mtu().map_err(|e| map_io_err("send_mtu", e))? as u16; + let recv_mtu = conn.recv_mtu().map_err(|e| map_io_err("recv_mtu", e))? as u16; + + // Log negotiated PHY for diagnostics (2M vs 1M) + match conn.as_ref().phy() { + Ok(phy) => { + debug!(addr = %remote, phy, send_mtu, recv_mtu, "BLE connection established") + } + Err(_) => { + debug!(addr = %remote, send_mtu, recv_mtu, "BLE connection established (PHY query unsupported)") + } + } + + Ok(Self { + conn, + remote, + send_mtu, + recv_mtu, + }) + } +} + +impl BleStream for BluerStream { + async fn send(&self, data: &[u8]) -> Result<(), TransportError> { + self.conn + .send(data) + .await + .map(|_| ()) + .map_err(|e| TransportError::SendFailed(format!("{}", e))) + } + + async fn recv(&self, buf: &mut [u8]) -> Result { + self.conn + .recv(buf) + .await + .map_err(|e| TransportError::RecvFailed(format!("{}", e))) + } + + fn send_mtu(&self) -> u16 { + self.send_mtu + } + + fn recv_mtu(&self) -> u16 { + self.recv_mtu + } + + fn remote_addr(&self) -> &BleAddr { + &self.remote + } +} + +// ---------------------------------------------------------------- +// BluerAcceptor +// ---------------------------------------------------------------- + +/// Acceptor wrapping a bluer L2CAP SeqPacketListener. +pub struct BluerAcceptor { + listener: SeqPacketListener, + adapter_name: String, +} + +impl BleAcceptor for BluerAcceptor { + type Stream = BluerStream; + + async fn accept(&mut self) -> Result { + let (conn, peer_sa) = self + .listener + .accept() + .await + .map_err(|e| map_io_err("accept", e))?; + + let remote = BleAddr::from_bluer(peer_sa.addr, &self.adapter_name); + BluerStream::new(conn, remote) + } +} + +// ---------------------------------------------------------------- +// BluerScanner +// ---------------------------------------------------------------- + +/// Scanner wrapping a bluer discovery event stream. +pub struct BluerScanner { + events: Pin + Send>>, + adapter: bluer::Adapter, + adapter_name: String, +} + +impl BleScanner for BluerScanner { + /// Yields adverts with the PSM and RSSI when BlueZ can supply them. + /// + /// The PSM comes out of the peer's Service Data AD structure (see + /// `super::super::psm`); a peer that advertises none — a legacy + /// UUID-only advertiser — yields `psm: None` and is dialled at the + /// configured PSM, exactly as before. + async fn next(&mut self) -> Option { + loop { + match self.events.next().await { + Some(AdapterEvent::DeviceAdded(addr)) => { + // Check if device advertises FIPS UUID + if let Ok(device) = self.adapter.device(addr) { + match device.uuids().await { + Ok(Some(uuids)) if uuids.contains(&FIPS_SERVICE_UUID) => { + let ble_addr = BleAddr::from_bluer(addr, &self.adapter_name); + let psm = + device.service_data().await.ok().flatten().and_then(|sd| { + sd.get(&PSM_SERVICE_DATA_UUID) + .and_then(|data| psm::decode_psm(data)) + }); + let rssi = device.rssi().await.ok().flatten(); + debug!(addr = %ble_addr, ?psm, ?rssi, "BLE scanner: FIPS peer found"); + return Some(ScanAdvert { + addr: ble_addr, + psm, + rssi, + }); + } + Ok(_) => { + trace!(addr = %addr, "BLE scanner: device without FIPS UUID"); + } + Err(e) => { + trace!(addr = %addr, error = %e, "BLE scanner: failed to read UUIDs"); + } + } + } + } + Some(_) => continue, + None => return None, + } + } + } +} + +// ---------------------------------------------------------------- +// BluerIo +// ---------------------------------------------------------------- + +/// Production BLE I/O implementation via BlueZ D-Bus (bluer crate). +pub struct BluerIo { + #[allow(dead_code)] // Session must be kept alive for the adapter. + session: bluer::Session, + adapter: bluer::Adapter, + adapter_name: String, + adv_handle: Mutex>, + mtu: u16, +} + +impl BluerIo { + /// Create a new BluerIo for the given adapter. + /// + /// Connects to BlueZ via D-Bus and powers on the adapter. + pub async fn new(adapter_name: &str, mtu: u16) -> Result { + let session = bluer::Session::new() + .await + .map_err(|e| map_err("Session::new", e))?; + + let adapter = if adapter_name == "default" { + session + .default_adapter() + .await + .map_err(|e| map_err("default_adapter", e))? + } else { + session + .adapter(adapter_name) + .map_err(|e| map_err("adapter", e))? + }; + + adapter + .set_powered(true) + .await + .map_err(|e| map_err("set_powered", e))?; + + let name = adapter.name().to_string(); + debug!(adapter = %name, "BluerIo initialized"); + + Ok(Self { + session, + adapter, + adapter_name: name, + adv_handle: Mutex::new(None), + mtu, + }) + } +} + +impl BleIo for BluerIo { + type Stream = BluerStream; + type Acceptor = BluerAcceptor; + type Scanner = BluerScanner; + + /// Binds the requested PSM and reports it back unchanged. + /// + /// BlueZ lets an application choose the PSM it binds, so the bound + /// PSM is always the requested one. Backends whose platform assigns + /// the PSM report something else; that is the reason for the return + /// value, not anything BlueZ does. + async fn listen(&self, psm: u16) -> Result<(Self::Acceptor, u16), TransportError> { + let local_addr = self + .adapter + .address() + .await + .map_err(|e| map_err("address", e))?; + + let sa = SocketAddr::new(local_addr, AddressType::LePublic, psm); + let listener = SeqPacketListener::bind(sa) + .await + .map_err(|e| map_io_err("bind", e))?; + + // Request high MTU for accepted connections + listener + .as_ref() + .set_recv_mtu(self.mtu) + .map_err(|e| map_io_err("set_recv_mtu", e))?; + + // Prevent sniff mode to reduce latency during data transfer + if let Err(e) = listener.as_ref().set_power_forced_active(true) { + debug!(error = %e, "BLE listener: set_power_forced_active not supported"); + } + + debug!(psm, mtu = self.mtu, "BLE listener bound"); + + Ok(( + BluerAcceptor { + listener, + adapter_name: self.adapter_name.clone(), + }, + psm, + )) + } + + async fn connect(&self, addr: &BleAddr, psm: u16) -> Result { + let target_sa = addr.to_socket_addr(psm); + + let socket = + Socket::::new_seq_packet().map_err(|e| map_io_err("new_seq_packet", e))?; + socket + .bind(SocketAddr::any_le()) + .map_err(|e| map_io_err("bind", e))?; + socket + .set_recv_mtu(self.mtu) + .map_err(|e| map_io_err("set_recv_mtu", e))?; + + // Prevent sniff mode to reduce latency during data transfer + if let Err(e) = socket.set_power_forced_active(true) { + debug!(error = %e, "BLE connect: set_power_forced_active not supported"); + } + + let conn = socket + .connect(target_sa) + .await + .map_err(|e| map_io_err("connect", e))?; + + let remote = addr.clone(); + BluerStream::new(conn, remote) + } + + /// Advertises the FIPS service UUID and the listener PSM. + /// + /// The PSM rides the Service Data AD structure specified in + /// `super::super::psm`. Emitting it costs the `local_name`: flags + /// (3) + 128-bit UUID list (18) + service data (6) fill 27 of the + /// 31-byte legacy PDU, and a name no longer fits. Peers that read + /// the service data dial the advertised PSM; legacy peers keep + /// dialling their configured one, which BlueZ listeners still bind. + /// + /// `super::super::psm` requires the PSM to ride the primary + /// advertisement, never the scan response, so a passive scanner + /// still sees it. Nothing here enforces that: BlueZ takes a set of + /// AD structures and chooses their placement itself. What keeps the + /// requirement holding is the arithmetic above — 27 of 31 bytes + /// used, so BlueZ has no reason to spill into the scan response — + /// and dropping the name is what makes it hold. + async fn start_advertising(&self, psm: u16) -> Result<(), TransportError> { + let adv = Advertisement { + advertisement_type: bluer::adv::Type::Peripheral, + service_uuids: { + let mut s = BTreeSet::new(); + s.insert(FIPS_SERVICE_UUID); + s + }, + service_data: { + let mut m = BTreeMap::new(); + m.insert(PSM_SERVICE_DATA_UUID, psm::encode_psm(psm).to_vec()); + m + }, + min_interval: Some(std::time::Duration::from_millis(400)), + max_interval: Some(std::time::Duration::from_millis(600)), + ..Default::default() + }; + + let handle = self + .adapter + .advertise(adv) + .await + .map_err(|e| map_err("advertise", e))?; + + *self.adv_handle.lock().await = Some(handle); + debug!(psm, "BLE advertising started"); + Ok(()) + } + + async fn stop_advertising(&self) -> Result<(), TransportError> { + let _ = self.adv_handle.lock().await.take(); + debug!("BLE advertising stopped"); + Ok(()) + } + + async fn start_scanning(&self) -> Result { + // Clear cached devices so BlueZ fires DeviceAdded for every + // advertisement. Without this, already-known devices only + // produce PropertyChanged events (which bluer doesn't expose + // at the device level), causing the scanner to miss peers + // after a daemon restart. + if let Ok(cached) = self.adapter.device_addresses().await { + let count = cached.len(); + for addr in cached { + let _ = self.adapter.remove_device(addr).await; + } + if count > 0 { + debug!(count, "BLE scanner: cleared cached devices"); + } + } + + // Set discovery filter for LE transport with FIPS UUID + let filter = DiscoveryFilter { + transport: DiscoveryTransport::Le, + uuids: { + let mut s = HashSet::new(); + s.insert(FIPS_SERVICE_UUID); + s + }, + ..Default::default() + }; + + self.adapter + .set_discovery_filter(filter) + .await + .map_err(|e| map_err("set_discovery_filter", e))?; + + let events = self + .adapter + .discover_devices() + .await + .map_err(|e| map_err("discover_devices", e))?; + + debug!("BLE scanning started"); + + Ok(BluerScanner { + events: Box::pin(events), + adapter: self.adapter.clone(), + adapter_name: self.adapter_name.clone(), + }) + } + + fn local_addr(&self) -> Result { + // Use futures::executor::block_on since this is a sync method + // but needs an async call. The adapter address is cached so + // the D-Bus call is fast. + let addr = futures::executor::block_on(self.adapter.address()) + .map_err(|e| map_err("address", e))?; + Ok(BleAddr::from_bluer(addr, &self.adapter_name)) + } + + fn adapter_name(&self) -> &str { + &self.adapter_name + } +} + +// Compile-time assertion that BluerIo satisfies Send + Sync. +#[allow(dead_code)] +fn _assert_bluer_io_send_sync() { + fn require() {} + require::(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A wrong shift in the base-UUID expansion would yield a plausible + /// UUID that simply never matches any peer — silent discovery + /// failure, not a build error. Companion to psm.rs's + /// `test_key_is_the_leading_16_bits_of_the_fips_uuid`. + #[test] + fn psm_service_data_uuid_expands_the_key_over_the_base_uuid() { + assert_eq!( + PSM_SERVICE_DATA_UUID, + "00009C90-0000-1000-8000-00805F9B34FB" + .parse::() + .unwrap() + ); + } +} diff --git a/src/transport/ble/mod.rs b/src/transport/ble/mod.rs index 16ab99db..61d6c916 100644 --- a/src/transport/ble/mod.rs +++ b/src/transport/ble/mod.rs @@ -11,7 +11,7 @@ //! may return a fragment of a packet or several packets coalesced from one //! read. The receive path therefore recovers boundaries from the FMP length //! prefix via [`stream_read::BleStreamRead`] and -//! [`crate::transport::framing::read_fmp_packet`], which is a transparent +//! `crate::transport::framing::read_fmp_packet`, which is a transparent //! pass-through on a boundary-preserving backend. //! //! ## Architecture @@ -29,8 +29,11 @@ pub mod addr; pub mod io; +#[cfg(bluer_available)] +pub mod io_linux; pub mod neighbor; pub mod pool; +pub mod psm; pub mod stats; pub mod stream_read; @@ -59,6 +62,12 @@ use tracing::{debug, info, trace, warn}; /// Default FIPS L2CAP PSM (Protocol Service Multiplexer). /// /// 0x0085 (133) is in the dynamic range (0x0080-0x00FF). +/// +/// This is a request and a fallback, not a guarantee. A backend whose +/// platform assigns the PSM reports back what it actually bound (see +/// [`io::BleIo::listen`]), and a peer that advertises its own PSM (see +/// [`psm`]) is dialled there instead. The configured value is what a peer is +/// dialled at when it advertises nothing. pub const DEFAULT_PSM: u16 = 0x0085; /// Concrete BLE transport type for use in TransportHandle. @@ -66,7 +75,7 @@ pub const DEFAULT_PSM: u16 = 0x0085; /// Production builds on glibc-linux use `BluerIo` (real BlueZ stack). /// Test builds, musl-linux, and non-Linux platforms use `MockBleIo`. #[cfg(all(bluer_available, not(test)))] -pub type DefaultBleTransport = BleTransport; +pub type DefaultBleTransport = BleTransport; #[cfg(any(not(bluer_available), test))] pub type DefaultBleTransport = BleTransport; @@ -177,9 +186,14 @@ impl BleTransport { } self.state = TransportState::Starting; - let psm = self.config.psm(); + let configured_psm = self.config.psm(); let adapter = self.io.adapter_name().to_string(); + // The PSM peers should dial us on. Only the listener knows it: a + // backend whose platform assigns PSMs reports back something other + // than what was requested, and that is what has to be advertised. + let mut listener_psm = configured_psm; + // Pre-compute local NodeAddr for cross-probe tie-breaking let local_node_addr = self.local_pubkey.and_then(|pk| { XOnlyPublicKey::from_slice(&pk) @@ -189,8 +203,9 @@ impl BleTransport { // Start L2CAP listener for inbound connections if self.config.accept_connections() { - match self.io.listen(psm).await { - Ok(acceptor) => { + match self.io.listen(configured_psm).await { + Ok((acceptor, bound_psm)) => { + listener_psm = bound_psm; let pool = Arc::clone(&self.pool); let packet_tx = self.packet_tx.clone(); let transport_id = self.transport_id; @@ -208,7 +223,12 @@ impl BleTransport { Arc::clone(&self.neighbor_buffer), local_node_addr, ))); - debug!(adapter = %adapter, psm = psm, "BLE accept loop started"); + debug!( + adapter = %adapter, + psm = listener_psm, + requested_psm = configured_psm, + "BLE accept loop started" + ); } Err(e) => { warn!(adapter = %adapter, error = %e, "failed to start BLE listener"); @@ -220,11 +240,15 @@ impl BleTransport { // Start continuous advertising if self.config.advertise() { - if let Err(e) = self.io.start_advertising().await { + if let Err(e) = self.io.start_advertising(listener_psm).await { warn!(adapter = %adapter, error = %e, "failed to start BLE advertising"); } else { self.stats.record_advertisement(); - debug!(adapter = %adapter, "BLE advertising started (continuous)"); + debug!( + adapter = %adapter, + psm = listener_psm, + "BLE advertising started (continuous)" + ); } } @@ -255,7 +279,7 @@ impl BleTransport { } self.state = TransportState::Up; - info!(adapter = %adapter, psm = psm, "BLE transport started"); + info!(adapter = %adapter, psm = listener_psm, "BLE transport started"); Ok(()) } @@ -999,7 +1023,7 @@ async fn scan_probe_loop( buffer: Arc, stats: Arc, local_pubkey: Option<[u8; 32]>, - psm: u16, + configured_psm: u16, connect_timeout_ms: u64, cooldown_secs: u64, local_node_addr: Option, @@ -1018,6 +1042,13 @@ async fn scan_probe_loop( // one per rotation, so entries are dropped once their node is no longer in // the pool — a peer that genuinely goes away is probed again normally. let mut known_node_of: HashMap = HashMap::new(); + // L2CAP listener PSMs read out of peers' advertisements. A peer whose + // platform assigns its listener PSM cannot be dialled at a configured + // constant, so it publishes the number it actually bound and we dial + // that. A peer that advertises nothing is dialled at `configured_psm`, + // which is every peer that predates this and every backend that does not + // advertise service data. + let mut learned_psm: HashMap = HashMap::new(); let cooldown = std::time::Duration::from_secs(cooldown_secs); let retry_interval = tokio::time::interval(std::time::Duration::from_secs(cooldown_secs)); tokio::pin!(retry_interval); @@ -1028,7 +1059,13 @@ async fn scan_probe_loop( let addr = tokio::select! { result = scanner.next() => { match result { - Some(a) => a, + Some(advert) => { + if let Some(psm) = advert.psm { + trace!(addr = %advert.addr, psm, "BLE scan: learned peer PSM"); + learned_psm.insert(advert.addr.clone(), psm); + } + advert.addr + } None => { debug!("BLE scanner ended"); break; @@ -1102,21 +1139,27 @@ async fn scan_probe_loop( } }; - // L2CAP connect + // L2CAP connect, at whatever PSM this peer advertised. + let dial_psm = learned_psm.get(&addr).copied().unwrap_or(configured_psm); let stream = match tokio::time::timeout( std::time::Duration::from_millis(connect_timeout_ms), - io.connect(&addr, psm), + io.connect(&addr, dial_psm), ) .await { Ok(Ok(s)) => s, Ok(Err(e)) => { - debug!(addr = %addr, error = %e, "BLE probe connect failed"); + debug!(addr = %addr, psm = dial_psm, error = %e, "BLE probe connect failed"); + // A learned PSM that does not answer is stale — forget it, so + // the next advert re-learns it and the fallback applies in the + // meantime. Costs one retry. + learned_psm.remove(&addr); continue; } Err(_) => { - debug!(addr = %addr, "BLE probe connect timeout"); + debug!(addr = %addr, psm = dial_psm, "BLE probe connect timeout"); stats.record_connect_timeout(); + learned_psm.remove(&addr); continue; } }; @@ -1735,6 +1778,149 @@ mod tests { transport.stop_async().await.unwrap(); } + // ------------------------------------------------------------------ + // Per-peer listener PSM + // ------------------------------------------------------------------ + + /// Every `(address, psm)` the transport tried to dial. + type DialLog = Arc>>; + + /// A scanning transport whose dials all fail, recording the PSM each was + /// attempted at. + fn psm_probe_transport( + dials: DialLog, + ) -> ( + BleTransport, + tokio::sync::mpsc::Receiver, + ) { + let io = MockBleIo::new("hci0", test_addr(1)); + io.set_connect_handler(move |addr, psm| { + dials.lock().unwrap().push((addr.clone(), psm)); + Err(TransportError::ConnectionRefused) + }); + let config = BleConfig { + adapter: Some("hci0".to_string()), + scan: Some(true), + advertise: Some(false), + accept_connections: Some(false), + probe_cooldown_secs: Some(1), + ..Default::default() + }; + let (tx, rx) = tokio::sync::mpsc::channel(64); + let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx); + transport.set_local_pubkey(test_pubkey(1)); + (transport, rx) + } + + /// A peer that advertises its listener PSM is dialled there, not at the + /// configured one — the whole point of learning it. + #[tokio::test(start_paused = true)] + async fn test_advertised_psm_is_dialled() { + let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new())); + let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials)); + transport.start_async().await.unwrap(); + + transport + .io + .inject_scan_advert(io::ScanAdvert::with_psm(test_addr(2), 0x00C1)) + .await; + settle().await; + + assert_eq!(dials.lock().unwrap().as_slice(), &[(test_addr(2), 0x00C1)]); + transport.stop_async().await.unwrap(); + } + + /// A legacy UUID-only advertiser carries no PSM, so the configured one is + /// used. This is the path every existing peer takes and it must not + /// regress. + #[tokio::test(start_paused = true)] + async fn test_advert_without_a_psm_falls_back_to_the_configured_one() { + let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new())); + let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials)); + transport.start_async().await.unwrap(); + + transport.io.inject_scan_result(test_addr(2)).await; + settle().await; + + assert_eq!( + dials.lock().unwrap().as_slice(), + &[(test_addr(2), DEFAULT_PSM)] + ); + transport.stop_async().await.unwrap(); + } + + /// A learned PSM that does not answer is forgotten, so a stale value + /// costs one retry rather than making the peer permanently unreachable. + #[tokio::test(start_paused = true)] + async fn test_a_failed_dial_forgets_the_learned_psm() { + let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new())); + let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials)); + transport.start_async().await.unwrap(); + + transport + .io + .inject_scan_advert(io::ScanAdvert::with_psm(test_addr(2), 0x00C1)) + .await; + settle().await; + assert_eq!(dials.lock().unwrap().len(), 1); + + // The retry after the cooldown must not repeat the PSM that failed. + tokio::time::advance(std::time::Duration::from_secs(3)).await; + settle().await; + + let log = dials.lock().unwrap().clone(); + assert!(log.len() >= 2, "the address is retried after the cooldown"); + assert_eq!(log[0], (test_addr(2), 0x00C1)); + assert!( + log[1..].iter().all(|(_, psm)| *psm == DEFAULT_PSM), + "retries fall back to the configured PSM: {log:?}" + ); + transport.stop_async().await.unwrap(); + } + + /// The advertisement carries the PSM the listener actually bound, not the + /// one that was requested. This is the whole OS-assigned-PSM case, with + /// no platform in the assertion. + #[tokio::test] + async fn test_the_advertised_psm_is_the_one_actually_bound() { + let io = MockBleIo::new("hci0", test_addr(1)); + io.set_bound_psm(0x00C1); + let config = BleConfig { + adapter: Some("hci0".to_string()), + scan: Some(false), + advertise: Some(true), + accept_connections: Some(true), + ..Default::default() + }; + let (tx, _rx) = tokio::sync::mpsc::channel(64); + let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx); + transport.start_async().await.unwrap(); + + assert_ne!(DEFAULT_PSM, 0x00C1, "test setup: the bound PSM differs"); + assert_eq!(transport.io.advertised_psm(), Some(0x00C1)); + transport.stop_async().await.unwrap(); + } + + /// A backend that binds what it was asked for advertises that — the BlueZ + /// path, unchanged. + #[tokio::test] + async fn test_a_backend_that_honours_the_request_advertises_it() { + let io = MockBleIo::new("hci0", test_addr(1)); + let config = BleConfig { + adapter: Some("hci0".to_string()), + scan: Some(false), + advertise: Some(true), + accept_connections: Some(true), + ..Default::default() + }; + let (tx, _rx) = tokio::sync::mpsc::channel(64); + let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx); + transport.start_async().await.unwrap(); + + assert_eq!(transport.io.advertised_psm(), Some(DEFAULT_PSM)); + transport.stop_async().await.unwrap(); + } + /// A peer that opens with something other than the exchange prefix is /// rejected before the framer ever sees the bytes. #[tokio::test] diff --git a/src/transport/ble/neighbor.rs b/src/transport/ble/neighbor.rs index e0b588c3..cc6b1c01 100644 --- a/src/transport/ble/neighbor.rs +++ b/src/transport/ble/neighbor.rs @@ -1,8 +1,9 @@ //! BLE neighbor detection via advertising and scanning. //! -//! BLE advertisements carry a 128-bit FIPS service UUID for identification. -//! Post-forklift, advertisements are UUID-only (no identity material); -//! identity is exchanged during the Noise handshake. +//! BLE advertisements carry a 128-bit FIPS service UUID for identification, +//! and optionally the advertiser's L2CAP listener PSM (see `super::psm`). +//! Post-forklift they carry no identity material; identity is exchanged +//! during the Noise handshake. use crate::transport::{DiscoveredPeer, TransportId}; use secp256k1::XOnlyPublicKey; diff --git a/src/transport/ble/psm.rs b/src/transport/ble/psm.rs new file mode 100644 index 00000000..442f3d50 --- /dev/null +++ b/src/transport/ble/psm.rs @@ -0,0 +1,176 @@ +//! Advertising the L2CAP listener PSM. +//! +//! A dialer has to know which PSM a peer's L2CAP listener is bound to. On +//! BlueZ an application can *choose* that number, so both ends can agree on a +//! configured constant. BlueZ is the exception: Android's +//! `listenUsingInsecureL2capChannel` and macOS's +//! `CBPeripheralManager.publishL2CAPChannel` both return an **OS-assigned** +//! PSM the application cannot request. A dialer cannot guess it, and before a +//! connection exists there is no channel to be told it on other than the +//! advertisement itself. +//! +//! This module is the wire specification for putting it there. It is +//! deliberately state-free: learning and caching belong to the scan/probe +//! loop, which already owns per-address state. +//! +//! # Wire layout +//! +//! The PSM rides a **Service Data — 16-bit UUID** AD structure (AD type +//! `0x16`) keyed on [`PSM_SERVICE_DATA_UUID16`], carrying the PSM as two +//! bytes little-endian. +//! +//! ## Why a 16-bit key, and not the FIPS service UUID +//! +//! A legacy advertising PDU carries 31 bytes of AD payload. Keying the +//! service data on the full 128-bit FIPS service UUID does not fit: +//! +//! | AD structure | bytes | +//! |-----------------------------------------------|-------| +//! | Flags | 3 | +//! | Complete list of 128-bit service UUIDs | 18 | +//! | Service Data — **128-bit** UUID + 2-byte PSM | 20 | +//! | **total** | **41** — over by 10 | +//! +//! Keying it on the 16-bit UUID [`PSM_SERVICE_DATA_UUID16`] does: +//! +//! | AD structure | bytes | +//! |-----------------------------------------------|-------| +//! | Flags | 3 | +//! | Complete list of 128-bit service UUIDs | 18 | +//! | Service Data — **16-bit** UUID + 2-byte PSM | 6 | +//! | **total** | **27** — fits | +//! +//! `0x9C90` is the leading 16 bits of the FIPS service UUID, expanded through +//! the Bluetooth base UUID (`00009C90-0000-1000-8000-00805F9B34FB`). The +//! budget is asserted at compile time below, so a change that reverts to a +//! 128-bit key fails the build rather than the radio. It also means an +//! advertiser using this layout has no room left for a local name. +//! +//! ## Why the primary advertisement, not the scan response +//! +//! A scan response only arrives after a successful active-scan +//! request/response round-trip, and that round-trip drops asymmetrically +//! across chipsets. Peers that never answer a scan request would become +//! undiscoverable rather than merely slower. The primary advertisement is +//! received passively on every advertising interval, so the PSM must ride it. +//! +//! ## Compatibility +//! +//! A reader ignores trailing bytes, so the value can be extended without +//! breaking older peers, and an advert with no service data at all decodes to +//! `None` — which is what every legacy UUID-only advertiser produces, and +//! what makes them keep working against the configured PSM. + +/// Service-data key for the advertised L2CAP PSM. +/// +/// The leading 16 bits of the FIPS service UUID, i.e. the Bluetooth +/// base-range UUID `00009C90-0000-1000-8000-00805F9B34FB`. Backends that +/// speak in whole UUIDs must expand it through the base UUID; backends that +/// speak in AD structures emit it as AD type `0x16`. +pub const PSM_SERVICE_DATA_UUID16: u16 = 0x9C90; + +/// AD payload budget of a legacy advertising PDU, in bytes. +const LEGACY_ADV_PAYLOAD_BYTES: usize = 31; + +/// Flags AD structure: length + type + one byte of flags. +const FLAGS_AD_BYTES: usize = 3; + +/// Complete list of 128-bit service UUIDs: length + type + one UUID. +const UUID128_LIST_AD_BYTES: usize = 2 + 16; + +/// Service data keyed on a 16-bit UUID: length + type + key + PSM. +const PSM_SERVICE_DATA_AD_BYTES: usize = 2 + 2 + PSM_ENCODED_LEN; + +/// Encoded width of the PSM value itself. +const PSM_ENCODED_LEN: usize = 2; + +/// The layout above must fit a legacy advertising PDU. If this fails, the +/// advert would be silently truncated or rejected by the controller. +const _: () = assert!( + FLAGS_AD_BYTES + UUID128_LIST_AD_BYTES + PSM_SERVICE_DATA_AD_BYTES <= LEGACY_ADV_PAYLOAD_BYTES, + "PSM advert layout exceeds the 31-byte legacy advertising PDU" +); + +/// Encode a PSM as advertised service data: two bytes, little-endian. +pub fn encode_psm(psm: u16) -> [u8; PSM_ENCODED_LEN] { + psm.to_le_bytes() +} + +/// Decode a PSM from advertised service data. +/// +/// Returns `None` for absent or truncated data — a legacy UUID-only +/// advertiser, which the caller answers by dialling the configured PSM. +/// Trailing bytes are ignored so the value can be extended later without +/// breaking readers built against this version. +pub fn decode_psm(data: &[u8]) -> Option { + if data.len() < PSM_ENCODED_LEN { + return None; + } + Some(u16::from_le_bytes([data[0], data[1]])) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_is_little_endian() { + assert_eq!(encode_psm(0x0085), [0x85, 0x00]); + assert_eq!(encode_psm(0x1234), [0x34, 0x12]); + } + + #[test] + fn test_round_trip() { + for psm in [0u16, 1, 0x0085, 0x00FF, 0x1234, u16::MAX] { + assert_eq!(decode_psm(&encode_psm(psm)), Some(psm), "psm {psm:#06x}"); + } + } + + #[test] + fn test_absent_service_data_decodes_to_none() { + // A legacy UUID-only advertiser carries no service data at all. + assert_eq!(decode_psm(&[]), None); + } + + #[test] + fn test_truncated_service_data_decodes_to_none() { + assert_eq!(decode_psm(&[0x85]), None); + } + + #[test] + fn test_trailing_bytes_are_ignored() { + // Forward compatibility: a future advertiser may append fields. + assert_eq!(decode_psm(&[0x85, 0x00, 0xFF, 0xFF]), Some(0x0085)); + } + + /// The byte budget is a build-time assertion, not a comment. This test + /// records the arithmetic it encodes so the numbers stay legible. + #[test] + fn test_advert_fits_the_legacy_pdu() { + assert_eq!(FLAGS_AD_BYTES, 3); + assert_eq!(UUID128_LIST_AD_BYTES, 18); + assert_eq!(PSM_SERVICE_DATA_AD_BYTES, 6); + assert_eq!( + FLAGS_AD_BYTES + UUID128_LIST_AD_BYTES + PSM_SERVICE_DATA_AD_BYTES, + 27 + ); + assert_eq!(LEGACY_ADV_PAYLOAD_BYTES, 31); + // A 128-bit service-data key would need 20 bytes, not 6 — the layout + // this module exists to reject. + assert_eq!(FLAGS_AD_BYTES + UUID128_LIST_AD_BYTES + 20, 41); + } + + #[test] + fn test_key_is_the_leading_16_bits_of_the_fips_uuid() { + // FIPS service UUID: 9c90b790-2cc5-42c0-9f87-c9cc40648f4c + const FIPS_SERVICE_UUID_U128: u128 = 0x9c90_b790_2cc5_42c0_9f87_c9cc_4064_8f4c; + assert_eq!( + PSM_SERVICE_DATA_UUID16, + (FIPS_SERVICE_UUID_U128 >> 112) as u16 + ); + } +} diff --git a/src/transport/ble/stream_read.rs b/src/transport/ble/stream_read.rs index 59b12d66..8df91c95 100644 --- a/src/transport/ble/stream_read.rs +++ b/src/transport/ble/stream_read.rs @@ -5,7 +5,7 @@ //! stream-oriented backend may return a fragment of a packet, or several //! packets coalesced, from a single read. This adapter turns the //! datagram-shaped [`BleStream`] into the [`AsyncRead`] that -//! [`crate::transport::framing::read_fmp_packet`] expects, buffering bytes +//! `crate::transport::framing::read_fmp_packet` expects, buffering bytes //! left over from one read into the next so packet boundaries are recovered //! from the FMP length prefix rather than trusted to the OS. //!