mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
feat(ble): per-peer PSM discovery core + compile BLE on macOS/Android
Foundation for "BLE v2" universal per-peer PSM discovery, shared by every BleIo backend. - ble/psm.rs: a 16-bit little-endian service-data PSM codec + a short-lived BleAddr->PSM map (learn/lookup/resolve/clear), with unit tests. Every node advertises its OS-assigned listener PSM and every dialer reads a peer's advertised PSM before connect(), replacing the fixed DEFAULT_PSM (0x0085) assumption that only BlueZ could satisfy (Android/macOS get OS-assigned listener PSMs). - Un-gate the BLE transport module from linux-only to a new `ble_available` cfg (linux/macos/android). The pool/discovery/psm logic and the generic BleTransport<I> are platform-agnostic; only the concrete BleIo backend is platform-specific (BluerIo on linux-glibc, else the MockBleIo fallback). The macOS BluestIo and Android AndroidIo backends, and the per-backend advertise/scan/connect wiring of the PSM map, land in follow-ups.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod addr;
|
||||
pub mod discovery;
|
||||
pub mod io;
|
||||
pub mod pool;
|
||||
pub mod psm;
|
||||
pub mod stats;
|
||||
|
||||
use super::{
|
||||
|
||||
@@ -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<u16> {
|
||||
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<HashMap<BleAddr, u16>>,
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
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<BleAddr, u16>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+22
-22
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user