Files
fips/src/transport/ble/neighbor.rs
T
ae93c90908 feat(transport/ble): put the L2CAP PSM in the seam, and implement it for BlueZ
The transport dialled every peer on one configured PSM and bound its own
listener to the same one. That works only because BlueZ lets an application
choose the PSM it binds, and 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.

So the PSM becomes a property of the seam rather than a per-backend
assumption. BleIo::listen reports the PSM it actually bound,
start_advertising takes the PSM to advertise, and BleScanner yields a
ScanAdvert -- address, plus PSM and RSSI when the backend can supply them --
instead of a bare address. The scan/probe loop keeps the learned PSM per
address alongside the probe-cooldown map it already maintains and passes it
into the existing connect(addr, psm), falling back to the configured PSM when
a peer advertises none.

The wire layout is a protocol decision and is documented in psm.rs with the
byte budget that forces it. A legacy advertising PDU carries 31 bytes; flags
take 3 and the 128-bit FIPS service UUID takes 18, which leaves too little for
service data keyed on that same 128-bit UUID. The PSM is therefore keyed on
the 16-bit UUID 0x9C90, the FIPS UUID's leading 16 bits through the Bluetooth
base UUID, costing 6 bytes for a total of 27. The budget is a const assertion,
so a change back to a 128-bit key fails the build rather than the radio. It
rides the primary advertisement, never the scan response, because a scan
response needs an active-scan round trip that drops asymmetrically across
chipsets.

The BlueZ backend now advertises and reads that service data, which is what
lets a BlueZ node tell an Android peer where to dial and learn the peer's
OS-assigned PSM in return. Emitting it costs the local_name, which no longer
fits the budget. Nothing reads a peer's advertised name -- discovery keys on
the service UUID alone, here and on maint -- so dropping it does not affect
which nodes can find each other.

Compatibility with deployed nodes is unchanged in both directions. BlueZ
listeners still bind the configured PSM, so an existing node dialling that PSM
still connects. A peer that advertises no service data yields psm: None and is
dialled at the configured PSM exactly as before. BleConfig::psm keeps its type,
default and meaning; only its doc comment changes to say it is now what to bind
and what to dial when a peer advertises nothing.

BlueZ shortens a base-range 128-bit UUID to its 16-bit form before building the
AD structure, so the service data goes out as the 6-byte AD type 0x16 the
layout requires rather than the 20-byte 0x21 form, and the advert stays inside
the PDU. Read in BlueZ 5.72: bt_string_to_uuid tests is_base_uuid128 first
(lib/uuid.c), and serialize_service_data emits BT_AD_SERVICE_DATA16 for a
2-byte UUID (src/shared/ad.c). This was the open question the change was held
on.

The BlueZ implementation moves out of io.rs into io_linux.rs at the same time.
io.rs now holds only what is platform-neutral -- the traits, ScanAdvert and the
mock -- so a new backend is a new io_<platform>.rs beside it rather than
another arm inside the shared file. The move is content-preserving: the only
changes to the relocated code are three import paths and two rustfmt reflows
caused by the dedent.

Co-authored-by: Arjen <18398758+Origami74@users.noreply.github.com>
2026-08-26 07:58:50 +01:00

128 lines
4.1 KiB
Rust

//! BLE neighbor detection via advertising and scanning.
//!
//! 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;
use std::sync::Mutex;
use super::addr::BleAddr;
/// Buffer for discovered BLE peers, drained by `discover()`.
///
/// Follows the same pattern as Ethernet's `NeighborBuffer`: peers are
/// added from the scan loop and drained by the node's neighbor polling.
pub struct NeighborBuffer {
transport_id: TransportId,
peers: Mutex<Vec<DiscoveredPeer>>,
}
impl NeighborBuffer {
/// Create a new empty neighbor buffer.
pub fn new(transport_id: TransportId) -> Self {
Self {
transport_id,
peers: Mutex::new(Vec::new()),
}
}
/// Add a discovered BLE peer.
///
/// Deduplicates by device address — keeps the latest entry.
pub fn add_peer(&self, addr: &BleAddr) {
let ta = addr.to_transport_addr();
let peer = DiscoveredPeer::new(self.transport_id, ta.clone());
let mut peers = self.peers.lock().unwrap_or_else(|e| e.into_inner());
// Deduplicate by address string
let addr_str = addr.to_string_repr();
peers.retain(|p| p.addr.as_str() != Some(addr_str.as_str()));
peers.push(peer);
}
/// Add a discovered BLE peer with a known public key.
///
/// Used after the pre-handshake pubkey exchange confirms the peer's
/// identity. The pubkey_hint enables the node's auto-connect path
/// to initiate the IK handshake.
pub fn add_peer_with_pubkey(&self, addr: &BleAddr, pubkey: XOnlyPublicKey) {
let ta = addr.to_transport_addr();
let peer = DiscoveredPeer::with_hint(self.transport_id, ta.clone(), pubkey);
let mut peers = self.peers.lock().unwrap_or_else(|e| e.into_inner());
let addr_str = addr.to_string_repr();
peers.retain(|p| p.addr.as_str() != Some(addr_str.as_str()));
peers.push(peer);
}
/// Drain all discovered peers since the last call.
pub fn take(&self) -> Vec<DiscoveredPeer> {
let mut peers = self.peers.lock().unwrap_or_else(|e| e.into_inner());
std::mem::take(&mut *peers)
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::TransportAddr;
fn test_addr(n: u8) -> BleAddr {
BleAddr {
adapter: "hci0".to_string(),
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
}
}
#[test]
fn test_neighbor_buffer_add_take() {
let buffer = NeighborBuffer::new(TransportId::new(1));
buffer.add_peer(&test_addr(1));
let peers = buffer.take();
assert_eq!(peers.len(), 1);
// Second take should be empty
let peers = buffer.take();
assert!(peers.is_empty());
}
#[test]
fn test_neighbor_buffer_dedup() {
let buffer = NeighborBuffer::new(TransportId::new(1));
buffer.add_peer(&test_addr(1));
buffer.add_peer(&test_addr(1)); // same address again
let peers = buffer.take();
assert_eq!(peers.len(), 1);
}
#[test]
fn test_neighbor_buffer_multiple_peers() {
let buffer = NeighborBuffer::new(TransportId::new(1));
buffer.add_peer(&test_addr(1));
buffer.add_peer(&test_addr(2));
buffer.add_peer(&test_addr(3));
let peers = buffer.take();
assert_eq!(peers.len(), 3);
}
#[test]
fn test_neighbor_buffer_transport_addr_format() {
let buffer = NeighborBuffer::new(TransportId::new(1));
buffer.add_peer(&test_addr(0x42));
let peers = buffer.take();
assert_eq!(
peers[0].addr,
TransportAddr::from_string("hci0/AA:BB:CC:DD:EE:42")
);
}
}