Files
fips/testing/ble/ble_spike.rs
Johnathan Corgan 89352d3218 Add BLE L2CAP transport with scan-based auto-connect
BLE transport implementation using L2CAP Connection-Oriented Channels
(SeqPacket mode) via the bluer crate, behind cfg(feature = "ble").

Core transport:
- BleTransport<I> generic over BleIo trait (BluerIo prod, MockBleIo test)
- Connection pool with priority eviction (static > discovered, max 7)
- Connect-on-send via connect_inline() matching TCP behavior
- Per-connection receive loops with pool cleanup on disconnect

Discovery and probing:
- Combined scan_probe_loop using select! over scanner events and a
  BinaryHeap delay queue with per-entry random jitter (0-5s) to prevent
  herd effects when multiple nodes see the same beacon simultaneously
- Pre-handshake pubkey exchange ([0x00][pubkey:32]) for IK identity
- Cross-probe tie-breaker: smaller NodeAddr's outbound wins (same
  convention as FMP/FSP rekey dual-initiation)
- Probed peers reported to DiscoveryBuffer; pool fills through normal
  node-layer auto-connect -> send_async -> connect_inline path

Beacon management:
- Periodic advertising: 1s burst every 30s (configurable via
  beacon_interval_secs / beacon_duration_secs)
- FIPS service UUID for scan filtering

Configuration (all fields optional with defaults):
- adapter, psm, mtu, max_connections, connect_timeout_ms
- advertise, scan, auto_connect, accept_connections
- beacon_interval_secs (30), beacon_duration_secs (1)

Hardware validated with two BLE nodes:
- 2048-byte MTU, ~60-160ms RTT, zero-config auto-connect
- BLE spike tool at testing/ble/ for standalone adapter validation

42 unit tests + 4 node-level integration tests, all CI-compatible
via MockBleIo (no hardware required). tokio test-util added for
time-dependent scan/probe tests.
2026-03-25 04:21:46 +00:00

364 lines
13 KiB
Rust

//! BLE L2CAP spike — validates bluer API assumptions for the BleIo trait.
//!
//! Usage:
//! ble_spike listen # Advertise + listen for L2CAP connections
//! ble_spike connect <MAC> # Scan + connect to a listening peer
//! ble_spike info # Print adapter info only
//!
//! Build: cd testing/ble && cargo build
//!
//! Test flow (two machines with BLE adapters):
//! host-a$ ./ble_spike listen
//! host-b$ ./ble_spike connect <host-a-MAC>
mod spike {
use bluer::l2cap::{SeqPacket, SeqPacketListener, Socket, SocketAddr};
use bluer::{adv::Advertisement, AdapterEvent, Address, AddressType, DiscoveryFilter, DiscoveryTransport};
use futures::StreamExt;
use std::collections::BTreeSet;
use std::pin::pin;
use tokio::time::{timeout, Duration};
/// FIPS BLE service UUID.
/// Derived from SHA-256("FIPS: welcome to cryptoanarchy") with UUID v4
/// version/variant bits applied.
const FIPS_SERVICE_UUID: bluer::Uuid = bluer::Uuid::from_u128(
0x9c90_b790_2cc5_42c0_9f87_c9cc_4064_8f4c,
);
/// L2CAP PSM for FIPS connections (dynamic range).
const FIPS_PSM: u16 = 0x0085;
/// Desired L2CAP CoC MTU (requested during negotiation).
/// Override with FIPS_BLE_MTU env var for testing asymmetric MTU.
fn desired_mtu() -> u16 {
std::env::var("FIPS_BLE_MTU")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2048)
}
/// Test message sent over L2CAP.
const TEST_MSG: &[u8] = b"FIPS BLE spike test";
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("info");
let session = bluer::Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
let addr = adapter.address().await?;
let name = adapter.name().to_string();
println!("Adapter: {} ({})", name, addr);
println!("Address type: {:?}", adapter.address_type().await?);
match cmd {
"info" => {
println!("Adapter info only. Use 'listen' or 'connect <MAC>' for testing.");
}
"listen" => listen(&adapter).await?,
"connect" => {
let mac = args.get(2).ok_or("usage: ble_spike connect <MAC>")?;
let target: Address = mac.parse().map_err(|_| format!("invalid MAC: {mac}"))?;
connect(&adapter, target).await?;
}
"sink" => sink(&adapter).await?,
"throughput" => {
let mac = args.get(2).ok_or("usage: ble_spike throughput <MAC>")?;
let target: Address = mac.parse().map_err(|_| format!("invalid MAC: {mac}"))?;
throughput(&adapter, target).await?;
}
other => {
eprintln!("unknown command: {other}");
eprintln!("usage: ble_spike [info|listen|connect|sink|throughput] <MAC>");
std::process::exit(1);
}
}
Ok(())
}
async fn listen(adapter: &bluer::Adapter) -> Result<(), Box<dyn std::error::Error>> {
// Start advertising the FIPS UUID
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-spike".to_string()),
..Default::default()
};
let adv_handle = adapter.advertise(adv).await?;
println!("Advertising FIPS UUID: {FIPS_SERVICE_UUID}");
// Bind L2CAP SeqPacket listener with requested MTU
let local_sa = SocketAddr::new(
adapter.address().await?,
AddressType::LePublic,
FIPS_PSM,
);
let listener = SeqPacketListener::bind(local_sa).await?;
listener.as_ref().set_recv_mtu(desired_mtu())?;
let mtu = desired_mtu();
println!("Listening on PSM 0x{FIPS_PSM:04X} (requested MTU: {mtu})");
println!("Waiting for connection... (Ctrl-C to stop)");
// Accept one connection
let (conn, peer_sa) = listener.accept().await?;
println!("Accepted connection from: {}", peer_sa.addr);
println!(" Send MTU: {}", conn.send_mtu()?);
println!(" Recv MTU: {}", conn.recv_mtu()?);
// Receive data
let mut buf = vec![0u8; 4096];
let n = conn.recv(&mut buf).await?;
println!(" Received {} bytes: {:?}", n, String::from_utf8_lossy(&buf[..n]));
// Send response
let response = b"FIPS BLE spike response";
let sent = conn.send(response).await?;
println!(" Sent {} bytes", sent);
// Receive and echo large payload
let n = timeout(Duration::from_secs(5), conn.recv(&mut buf)).await??;
println!(" Received large payload: {} bytes", n);
let echoed = conn.send(&buf[..n]).await?;
println!(" Echoed {} bytes", echoed);
// Wait a moment before cleanup
tokio::time::sleep(Duration::from_secs(2)).await;
drop(adv_handle);
println!("Done.");
Ok(())
}
async fn connect(
adapter: &bluer::Adapter,
target: Address,
) -> Result<(), Box<dyn std::error::Error>> {
// Optional: scan for the target first to verify it's advertising
println!("Scanning for target {target}...");
let filter = DiscoveryFilter {
transport: DiscoveryTransport::Le,
..Default::default()
};
adapter.set_discovery_filter(filter).await?;
let events = adapter.discover_devices().await?;
let mut events = pin!(events);
let found = timeout(Duration::from_secs(10), async {
while let Some(event) = events.next().await {
if let AdapterEvent::DeviceAdded(addr) = event {
println!(" Discovered: {addr}");
if addr == target {
// Check if it has our UUID
if let Ok(device) = adapter.device(addr) {
if let Ok(Some(uuids)) = device.uuids().await {
if uuids.contains(&FIPS_SERVICE_UUID) {
println!(" -> Found FIPS service UUID!");
return true;
}
}
}
println!(" -> Target found (no UUID filter match, connecting anyway)");
return true;
}
}
}
false
})
.await;
match found {
Ok(true) => println!("Target found."),
Ok(false) => println!("Scan ended without finding target, trying connect anyway..."),
Err(_) => println!("Scan timeout, trying connect anyway..."),
}
// Stop scanning before connecting
drop(events);
// Connect via L2CAP SeqPacket with requested MTU
let target_sa = SocketAddr::new(target, AddressType::LePublic, FIPS_PSM);
let mtu = desired_mtu();
println!("Connecting to {target} on PSM 0x{FIPS_PSM:04X} (requested MTU: {mtu})...");
let socket = Socket::<SeqPacket>::new_seq_packet()?;
socket.bind(SocketAddr::any_le())?;
socket.set_recv_mtu(desired_mtu())?;
let conn = timeout(Duration::from_secs(15), socket.connect(target_sa)).await??;
println!("Connected!");
println!(" Send MTU: {}", conn.send_mtu()?);
println!(" Recv MTU: {}", conn.recv_mtu()?);
println!(" Peer: {}", conn.peer_addr()?.addr);
// Send small test message
let sent = conn.send(TEST_MSG).await?;
println!(" Sent {} bytes: {:?}", sent, String::from_utf8_lossy(TEST_MSG));
// Receive response
let mut buf = vec![0u8; 4096];
let n = timeout(Duration::from_secs(5), conn.recv(&mut buf)).await??;
println!(" Received {} bytes: {:?}", n, String::from_utf8_lossy(&buf[..n]));
// Send large payload to test MTU
let send_mtu = conn.send_mtu()?;
let large = vec![0xAB_u8; send_mtu];
let sent = conn.send(&large).await?;
println!(" Sent large payload: {} bytes (send_mtu={})", sent, send_mtu);
let n = timeout(Duration::from_secs(5), conn.recv(&mut buf)).await??;
println!(" Received large echo: {} bytes", n);
println!("Done.");
Ok(())
}
/// Sink mode: advertise, accept connection, receive and count bytes for 10s.
async fn sink(adapter: &bluer::Adapter) -> Result<(), Box<dyn std::error::Error>> {
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-sink".to_string()),
..Default::default()
};
let _adv_handle = adapter.advertise(adv).await?;
let local_sa = SocketAddr::new(
adapter.address().await?,
AddressType::LePublic,
FIPS_PSM,
);
let listener = SeqPacketListener::bind(local_sa).await?;
listener.as_ref().set_recv_mtu(desired_mtu())?;
println!("Sink waiting for connection...");
let (conn, peer_sa) = listener.accept().await?;
println!(
"Connected from {} (send_mtu={}, recv_mtu={})",
peer_sa.addr,
conn.send_mtu()?,
conn.recv_mtu()?,
);
let mut buf = vec![0u8; 8192];
let mut total_bytes: u64 = 0;
let mut total_msgs: u64 = 0;
let start = std::time::Instant::now();
loop {
match conn.recv(&mut buf).await {
Ok(0) => break,
Ok(n) => {
total_bytes += n as u64;
total_msgs += 1;
}
Err(_) => break,
}
}
let elapsed = start.elapsed();
let secs = elapsed.as_secs_f64();
let kbps = (total_bytes as f64 * 8.0) / (secs * 1000.0);
println!(
"Received {} bytes in {} messages over {:.2}s ({:.1} kbps)",
total_bytes, total_msgs, secs, kbps,
);
Ok(())
}
/// Throughput mode: connect and blast data for 10 seconds.
async fn throughput(
adapter: &bluer::Adapter,
target: Address,
) -> Result<(), Box<dyn std::error::Error>> {
// Scan for target
println!("Scanning for {target}...");
let filter = DiscoveryFilter {
transport: DiscoveryTransport::Le,
..Default::default()
};
adapter.set_discovery_filter(filter).await?;
let events = adapter.discover_devices().await?;
let mut events = pin!(events);
let _ = timeout(Duration::from_secs(10), async {
while let Some(event) = events.next().await {
if let AdapterEvent::DeviceAdded(addr) = event {
if addr == target {
return;
}
}
}
})
.await;
drop(events);
// Connect with MTU
let target_sa = SocketAddr::new(target, AddressType::LePublic, FIPS_PSM);
let socket = Socket::<SeqPacket>::new_seq_packet()?;
socket.bind(SocketAddr::any_le())?;
socket.set_recv_mtu(desired_mtu())?;
let conn = timeout(Duration::from_secs(15), socket.connect(target_sa)).await??;
let send_mtu = conn.send_mtu()?;
println!(
"Connected (send_mtu={}, recv_mtu={}). Sending for 10s...",
send_mtu,
conn.recv_mtu()?,
);
let payload = vec![0xAB_u8; send_mtu];
let mut total_bytes: u64 = 0;
let mut total_msgs: u64 = 0;
let mut errors: u64 = 0;
let start = std::time::Instant::now();
let duration = Duration::from_secs(10);
while start.elapsed() < duration {
match conn.send(&payload).await {
Ok(n) => {
total_bytes += n as u64;
total_msgs += 1;
}
Err(_) => {
errors += 1;
if errors > 10 {
println!("Too many errors, stopping.");
break;
}
}
}
}
let elapsed = start.elapsed();
let secs = elapsed.as_secs_f64();
let kbps = (total_bytes as f64 * 8.0) / (secs * 1000.0);
println!(
"Sent {} bytes in {} messages over {:.2}s ({:.1} kbps, {} errors)",
total_bytes, total_msgs, secs, kbps, errors,
);
// Close cleanly
let _ = conn.shutdown(std::net::Shutdown::Both);
Ok(())
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
if let Err(e) = spike::run().await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}