Skip to main content

fips_core/transport/ble/
mod.rs

1//! BLE L2CAP Transport Implementation
2//!
3//! Provides BLE-based transport for FIPS peer communication using L2CAP
4//! Connection-Oriented Channels (CoC) in SeqPacket mode. L2CAP CoC
5//! preserves message boundaries (unlike TCP byte streams), so no FMP
6//! framing is needed — each send/recv is one FIPS packet.
7//!
8//! ## Architecture
9//!
10//! Transport logic (pool, discovery, lifecycle) is separated from the
11//! BlueZ/bluer stack via the `BleIo` trait. `BluerIo` provides the real
12//! implementation (behind `cfg(bluer_available)`); `MockBleIo` provides
13//! an in-memory test double for CI without hardware.
14//!
15//! ## Connection Pool
16//!
17//! BLE hardware limits concurrent connections (typically 4-10). The pool
18//! enforces a configurable maximum (default 7) with priority eviction:
19//! static (configured) peers get priority over discovered peers.
20
21pub mod addr;
22pub mod discovery;
23pub mod io;
24pub mod pool;
25pub mod stats;
26mod tasks;
27
28use tasks::{accept_loop, pubkey_exchange, receive_loop, scan_probe_loop};
29
30use super::{
31    ConnectionState, DiscoveredPeer, PacketTx, Transport, TransportAddr, TransportError,
32    TransportId, TransportState, TransportType,
33};
34use crate::config::BleConfig;
35use crate::identity::NodeAddr;
36use addr::BleAddr;
37use discovery::DiscoveryBuffer;
38use io::{BleIo, BleStream};
39use pool::{BleConnection, ConnectionPool};
40use stats::BleStats;
41
42use secp256k1::XOnlyPublicKey;
43use std::collections::HashMap;
44use std::sync::Arc;
45use tokio::sync::Mutex;
46use tokio::task::JoinHandle;
47use tracing::{debug, info, warn};
48
49/// Default FIPS L2CAP PSM (Protocol Service Multiplexer).
50///
51/// 0x0085 (133) is in the dynamic range (0x0080-0x00FF).
52pub const DEFAULT_PSM: u16 = 0x0085;
53
54/// Concrete BLE transport type for use in TransportHandle.
55///
56/// Production builds on glibc-linux use `BluerIo` (real BlueZ stack).
57/// Test builds, musl-linux, and non-Linux platforms use `MockBleIo`.
58#[cfg(all(bluer_available, not(test)))]
59pub type DefaultBleTransport = BleTransport<io::BluerIo>;
60
61#[cfg(any(not(bluer_available), test))]
62pub type DefaultBleTransport = BleTransport<io::MockBleIo>;
63
64// ============================================================================
65// BLE Transport
66// ============================================================================
67
68/// BLE transport for FIPS.
69///
70/// Provides connection-oriented, reliable delivery over BLE L2CAP CoC.
71/// Each peer has its own L2CAP connection; the pool enforces hardware
72/// connection limits with priority eviction.
73pub struct BleTransport<I: BleIo> {
74    /// Unique transport identifier.
75    transport_id: TransportId,
76    /// Optional instance name.
77    name: Option<String>,
78    /// Configuration.
79    config: BleConfig,
80    /// Current state.
81    state: TransportState,
82    /// BLE I/O implementation (BluerIo or MockBleIo).
83    io: Arc<I>,
84    /// Established connection pool.
85    pool: Arc<Mutex<ConnectionPool<Arc<I::Stream>>>>,
86    /// Pending connection attempts.
87    connecting: Arc<Mutex<HashMap<TransportAddr, ConnectingEntry>>>,
88    /// Channel for delivering received packets to Node.
89    packet_tx: PacketTx,
90    /// Accept loop task handle.
91    accept_task: Option<JoinHandle<()>>,
92    /// Combined scan + probe loop task handle.
93    scan_probe_task: Option<JoinHandle<()>>,
94    /// Discovery buffer for discovered peers.
95    discovery_buffer: Arc<DiscoveryBuffer>,
96    /// Transport statistics.
97    stats: Arc<BleStats>,
98    /// Our public key for pre-handshake identity exchange.
99    ///
100    /// BLE advertisements carry only the FIPS UUID, not the pubkey.
101    /// After L2CAP connection, both sides exchange `[0x00][pubkey:32]`
102    /// so the node layer can initiate the IK handshake.
103    /// Temporary — removed when FMP switches to XX.
104    local_pubkey: Option<[u8; 32]>,
105}
106
107/// A pending background connection attempt.
108struct ConnectingEntry {
109    task: JoinHandle<()>,
110}
111
112impl<I: BleIo> BleTransport<I> {
113    /// Create a new BLE transport.
114    pub fn new(
115        transport_id: TransportId,
116        name: Option<String>,
117        config: BleConfig,
118        io: I,
119        packet_tx: PacketTx,
120    ) -> Self {
121        let max_conns = config.max_connections();
122        Self {
123            transport_id,
124            name,
125            config,
126            state: TransportState::Configured,
127            io: Arc::new(io),
128            pool: Arc::new(Mutex::new(ConnectionPool::new(max_conns))),
129            connecting: Arc::new(Mutex::new(HashMap::new())),
130            packet_tx,
131            accept_task: None,
132            scan_probe_task: None,
133            discovery_buffer: Arc::new(DiscoveryBuffer::new(transport_id)),
134            stats: Arc::new(BleStats::new()),
135            local_pubkey: None,
136        }
137    }
138
139    /// Get the instance name.
140    pub fn name(&self) -> Option<&str> {
141        self.name.as_deref()
142    }
143
144    /// Get the transport statistics.
145    pub fn stats(&self) -> &Arc<BleStats> {
146        &self.stats
147    }
148
149    /// Get the I/O implementation (for test injection).
150    pub fn io(&self) -> &Arc<I> {
151        &self.io
152    }
153
154    /// Set the local public key for pre-handshake identity exchange.
155    ///
156    /// Must be called before `start_async()`. Without this, BLE
157    /// connections skip the pubkey exchange and discovered peers
158    /// won't have identity information for auto-connect.
159    pub fn set_local_pubkey(&mut self, pubkey: [u8; 32]) {
160        self.local_pubkey = Some(pubkey);
161    }
162
163    /// Start the transport asynchronously.
164    pub async fn start_async(&mut self) -> Result<(), TransportError> {
165        if !self.state.can_start() {
166            return Err(TransportError::AlreadyStarted);
167        }
168        self.state = TransportState::Starting;
169
170        let psm = self.config.psm();
171        let adapter = self.io.adapter_name().to_string();
172
173        // Pre-compute local NodeAddr for cross-probe tie-breaking
174        let local_node_addr = self.local_pubkey.and_then(|pk| {
175            XOnlyPublicKey::from_slice(&pk)
176                .ok()
177                .map(|xonly| NodeAddr::from_pubkey(&xonly))
178        });
179
180        // Start L2CAP listener for inbound connections
181        if self.config.accept_connections() {
182            match self.io.listen(psm).await {
183                Ok(acceptor) => {
184                    let pool = Arc::clone(&self.pool);
185                    let packet_tx = self.packet_tx.clone();
186                    let transport_id = self.transport_id;
187                    let stats = Arc::clone(&self.stats);
188                    let max_conns = self.config.max_connections();
189
190                    self.accept_task = Some(tokio::spawn(accept_loop(
191                        acceptor,
192                        pool,
193                        packet_tx,
194                        transport_id,
195                        stats,
196                        max_conns,
197                        self.local_pubkey,
198                        Arc::clone(&self.discovery_buffer),
199                        local_node_addr,
200                    )));
201                    debug!(adapter = %adapter, psm = psm, "BLE accept loop started");
202                }
203                Err(e) => {
204                    warn!(adapter = %adapter, error = %e, "failed to start BLE listener");
205                    self.state = TransportState::Failed;
206                    return Err(e);
207                }
208            }
209        }
210
211        // Start continuous advertising
212        if self.config.advertise() {
213            if let Err(e) = self.io.start_advertising().await {
214                warn!(adapter = %adapter, error = %e, "failed to start BLE advertising");
215            } else {
216                self.stats.record_advertisement();
217                debug!(adapter = %adapter, "BLE advertising started (continuous)");
218            }
219        }
220
221        // Start combined scan + probe loop
222        if self.config.scan() {
223            match self.io.start_scanning().await {
224                Ok(scanner) => {
225                    self.scan_probe_task = Some(tokio::spawn(scan_probe_loop::<I>(
226                        scanner,
227                        Arc::clone(&self.io),
228                        Arc::clone(&self.pool),
229                        Arc::clone(&self.discovery_buffer),
230                        Arc::clone(&self.stats),
231                        self.local_pubkey,
232                        self.config.psm(),
233                        self.config.connect_timeout_ms(),
234                        self.config.probe_cooldown_secs(),
235                        local_node_addr,
236                        self.packet_tx.clone(),
237                        self.transport_id,
238                    )));
239                    debug!(adapter = %adapter, "BLE scan+probe loop started");
240                }
241                Err(e) => {
242                    warn!(adapter = %adapter, error = %e, "failed to start BLE scanning");
243                }
244            }
245        }
246
247        self.state = TransportState::Up;
248        info!(adapter = %adapter, psm = psm, "BLE transport started");
249        Ok(())
250    }
251
252    /// Stop the transport asynchronously.
253    pub async fn stop_async(&mut self) -> Result<(), TransportError> {
254        // Stop advertising
255        let _ = self.io.stop_advertising().await;
256
257        // Abort accept loop
258        if let Some(task) = self.accept_task.take() {
259            task.abort();
260        }
261
262        // Abort scan+probe loop
263        if let Some(task) = self.scan_probe_task.take() {
264            task.abort();
265        }
266
267        // Drain connecting pool
268        {
269            let mut connecting = self.connecting.lock().await;
270            for (_, entry) in connecting.drain() {
271                entry.task.abort();
272            }
273        }
274
275        // Drain established connections (recv tasks aborted via Drop)
276        {
277            let mut pool = self.pool.lock().await;
278            for addr in pool.addrs() {
279                pool.remove(&addr);
280            }
281        }
282
283        self.state = TransportState::Down;
284        info!("BLE transport stopped");
285        Ok(())
286    }
287
288    /// Send data to a remote BLE address.
289    ///
290    /// If no connection exists, triggers a background connect and fails
291    /// fast. The next send retry (typically 1s later for handshake msg1)
292    /// will find the connection established. This avoids blocking the
293    /// event loop on L2CAP connect (up to 10s).
294    pub async fn send_async(
295        &self,
296        addr: &TransportAddr,
297        data: &[u8],
298    ) -> Result<usize, TransportError> {
299        let pool = self.pool.lock().await;
300        let conn = match pool.get(addr) {
301            Some(c) => c,
302            None => {
303                // Drop pool lock before triggering background connect
304                drop(pool);
305                // Fire-and-forget: connect_async spawns a background task
306                let _ = self.connect_async(addr).await;
307                return Err(TransportError::SendFailed("not connected".into()));
308            }
309        };
310
311        // MTU check
312        let mtu = conn.effective_mtu() as usize;
313        if data.len() > mtu {
314            self.stats.record_mtu_exceeded();
315            return Err(TransportError::MtuExceeded {
316                packet_size: data.len(),
317                mtu: mtu as u16,
318            });
319        }
320
321        match conn.stream.send(data).await {
322            Ok(()) => {
323                self.stats.record_send(data.len());
324                Ok(data.len())
325            }
326            Err(e) => {
327                self.stats.record_send_error();
328                // Drop pool lock before removing to avoid deadlock
329                drop(pool);
330                let mut pool = self.pool.lock().await;
331                pool.remove(addr);
332                warn!(addr = %addr, error = %e, "BLE send failed, connection removed");
333                Err(e)
334            }
335        }
336    }
337
338    /// Connect to a remote BLE device inline (blocking the caller).
339    ///
340    /// Not used in normal operation (send_async fails fast instead).
341    /// Retained for manual debugging / testing scenarios.
342    #[allow(dead_code)]
343    async fn connect_inline(&self, addr: &TransportAddr) -> Result<(), TransportError> {
344        let ble_addr = BleAddr::parse(
345            addr.as_str()
346                .ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?,
347        )?;
348
349        let psm = self.config.psm();
350        let timeout_ms = self.config.connect_timeout_ms();
351
352        let stream = match tokio::time::timeout(
353            std::time::Duration::from_millis(timeout_ms),
354            self.io.connect(&ble_addr, psm),
355        )
356        .await
357        {
358            Ok(Ok(stream)) => stream,
359            Ok(Err(e)) => {
360                debug!(addr = %addr, error = %e, "BLE connect-on-send failed");
361                return Err(TransportError::ConnectionRefused);
362            }
363            Err(_) => {
364                self.stats.record_connect_timeout();
365                debug!(addr = %addr, "BLE connect-on-send timeout");
366                return Err(TransportError::Timeout);
367            }
368        };
369
370        // Pre-handshake pubkey exchange (temporary, pre-XX)
371        if let Some(ref our_pubkey) = self.local_pubkey {
372            match pubkey_exchange(&stream, our_pubkey).await {
373                Ok(peer_pubkey) => {
374                    debug!(addr = %addr, "BLE outbound pubkey exchange complete");
375                    self.discovery_buffer
376                        .add_peer_with_pubkey(&ble_addr, peer_pubkey);
377                }
378                Err(e) => {
379                    warn!(addr = %addr, error = %e, "BLE outbound pubkey exchange failed");
380                    return Err(e);
381                }
382            }
383        }
384
385        self.promote_connection(addr, &ble_addr, stream).await
386    }
387
388    /// Promote a newly established stream into the connection pool.
389    ///
390    /// Spawns the receive loop and inserts into the pool with eviction.
391    async fn promote_connection(
392        &self,
393        addr: &TransportAddr,
394        ble_addr: &BleAddr,
395        stream: I::Stream,
396    ) -> Result<(), TransportError> {
397        let send_mtu = stream.send_mtu();
398        let recv_mtu = stream.recv_mtu();
399        let stream = Arc::new(stream);
400
401        let recv_task = tokio::spawn(receive_loop(
402            Arc::clone(&stream),
403            addr.clone(),
404            Arc::clone(&self.pool),
405            self.packet_tx.clone(),
406            self.transport_id,
407            Arc::clone(&self.stats),
408            recv_mtu,
409        ));
410
411        let conn = BleConnection {
412            stream,
413            recv_task: Some(recv_task),
414            send_mtu,
415            recv_mtu,
416            established_at: tokio::time::Instant::now(),
417            is_static: false,
418            addr: ble_addr.clone(),
419        };
420
421        let mut pool = self.pool.lock().await;
422        match pool.insert(addr.clone(), conn) {
423            Ok(Some(evicted)) => {
424                self.stats.record_pool_eviction();
425                debug!(addr = %addr, evicted = %evicted, "BLE connection established (evicted peer)");
426            }
427            Ok(None) => {
428                debug!(addr = %addr, "BLE connection established");
429            }
430            Err(e) => {
431                warn!(addr = %addr, error = %e, "BLE pool full, connection dropped");
432                self.stats.record_connection_rejected();
433                return Err(TransportError::SendFailed("pool full".into()));
434            }
435        }
436        self.stats.record_connection_established();
437        Ok(())
438    }
439
440    /// Initiate a non-blocking connection to a remote BLE device.
441    ///
442    /// Spawns a background task that connects with timeout and promotes
443    /// to the pool on success. Poll `connection_state_sync()` to check.
444    pub async fn connect_async(&self, addr: &TransportAddr) -> Result<(), TransportError> {
445        // Already connected?
446        {
447            let pool = self.pool.lock().await;
448            if pool.contains(addr) {
449                return Ok(());
450            }
451        }
452
453        // Already connecting?
454        {
455            let connecting = self.connecting.lock().await;
456            if connecting.contains_key(addr) {
457                return Ok(());
458            }
459        }
460
461        let ble_addr = BleAddr::parse(
462            addr.as_str()
463                .ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?,
464        )?;
465
466        let io = Arc::clone(&self.io);
467        let pool = Arc::clone(&self.pool);
468        let connecting = Arc::clone(&self.connecting);
469        let packet_tx = self.packet_tx.clone();
470        let transport_id = self.transport_id;
471        let stats = Arc::clone(&self.stats);
472        let psm = self.config.psm();
473        let timeout_ms = self.config.connect_timeout_ms();
474        let addr_clone = addr.clone();
475        let local_pubkey = self.local_pubkey;
476        let discovery_buffer = Arc::clone(&self.discovery_buffer);
477
478        let task = tokio::spawn(async move {
479            let result = tokio::time::timeout(
480                std::time::Duration::from_millis(timeout_ms),
481                io.connect(&ble_addr, psm),
482            )
483            .await;
484
485            // Remove from connecting pool
486            connecting.lock().await.remove(&addr_clone);
487
488            match result {
489                Ok(Ok(stream)) => {
490                    // Pre-handshake pubkey exchange (temporary, pre-XX)
491                    if let Some(ref our_pubkey) = local_pubkey {
492                        match pubkey_exchange(&stream, our_pubkey).await {
493                            Ok(peer_pubkey) => {
494                                debug!(addr = %addr_clone, "BLE outbound pubkey exchange complete");
495                                discovery_buffer.add_peer_with_pubkey(&ble_addr, peer_pubkey);
496                            }
497                            Err(e) => {
498                                warn!(
499                                    addr = %addr_clone, error = %e,
500                                    "BLE outbound pubkey exchange failed"
501                                );
502                                return;
503                            }
504                        }
505                    }
506
507                    let send_mtu = stream.send_mtu();
508                    let recv_mtu = stream.recv_mtu();
509                    let stream = Arc::new(stream);
510
511                    let recv_task = tokio::spawn(receive_loop(
512                        Arc::clone(&stream),
513                        addr_clone.clone(),
514                        Arc::clone(&pool),
515                        packet_tx,
516                        transport_id,
517                        Arc::clone(&stats),
518                        recv_mtu,
519                    ));
520
521                    let conn = BleConnection {
522                        stream,
523                        recv_task: Some(recv_task),
524                        send_mtu,
525                        recv_mtu,
526                        established_at: tokio::time::Instant::now(),
527                        is_static: false,
528                        addr: ble_addr,
529                    };
530
531                    let mut pool = pool.lock().await;
532                    match pool.insert(addr_clone.clone(), conn) {
533                        Ok(Some(evicted)) => {
534                            stats.record_pool_eviction();
535                            debug!(addr = %addr_clone, evicted = %evicted, "BLE connection established (evicted peer)");
536                        }
537                        Ok(None) => {
538                            debug!(addr = %addr_clone, "BLE connection established");
539                        }
540                        Err(e) => {
541                            warn!(addr = %addr_clone, error = %e, "BLE pool full, connection dropped");
542                            stats.record_connection_rejected();
543                            return;
544                        }
545                    }
546                    stats.record_connection_established();
547                }
548                Ok(Err(e)) => {
549                    debug!(addr = %addr_clone, error = %e, "BLE connect failed");
550                }
551                Err(_) => {
552                    stats.record_connect_timeout();
553                    debug!(addr = %addr_clone, "BLE connect timeout");
554                }
555            }
556        });
557
558        self.connecting
559            .lock()
560            .await
561            .insert(addr.clone(), ConnectingEntry { task });
562
563        Ok(())
564    }
565
566    /// Query the state of a connection attempt.
567    pub fn connection_state_sync(&self, addr: &TransportAddr) -> ConnectionState {
568        // Check established pool (try_lock to avoid blocking)
569        if let Ok(pool) = self.pool.try_lock()
570            && pool.contains(addr)
571        {
572            return ConnectionState::Connected;
573        }
574
575        // Check connecting pool
576        if let Ok(connecting) = self.connecting.try_lock()
577            && connecting.contains_key(addr)
578        {
579            return ConnectionState::Connecting;
580        }
581
582        ConnectionState::None
583    }
584
585    /// Close a specific connection.
586    pub async fn close_connection_async(&self, addr: &TransportAddr) {
587        let mut pool = self.pool.lock().await;
588        if let Some(conn) = pool.remove(addr) {
589            debug!(addr = %addr, "BLE connection closed");
590            drop(conn); // recv_task aborted via Drop
591        }
592    }
593
594    /// Get the link MTU for a specific address.
595    pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
596        if let Ok(pool) = self.pool.try_lock()
597            && let Some(conn) = pool.get(addr)
598        {
599            return conn.effective_mtu();
600        }
601        self.config.mtu()
602    }
603}
604
605impl<I: BleIo> Transport for BleTransport<I> {
606    fn transport_id(&self) -> TransportId {
607        self.transport_id
608    }
609
610    fn transport_type(&self) -> &TransportType {
611        &TransportType::BLE
612    }
613
614    fn state(&self) -> TransportState {
615        self.state
616    }
617
618    fn mtu(&self) -> u16 {
619        self.config.mtu()
620    }
621
622    fn link_mtu(&self, addr: &TransportAddr) -> u16 {
623        self.link_mtu(addr)
624    }
625
626    fn start(&mut self) -> Result<(), TransportError> {
627        Err(TransportError::NotSupported(
628            "use start_async() for BLE transport".into(),
629        ))
630    }
631
632    fn stop(&mut self) -> Result<(), TransportError> {
633        Err(TransportError::NotSupported(
634            "use stop_async() for BLE transport".into(),
635        ))
636    }
637
638    fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> {
639        Err(TransportError::NotSupported(
640            "use send_async() for BLE transport".into(),
641        ))
642    }
643
644    fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
645        Ok(self.discovery_buffer.take())
646    }
647
648    fn auto_connect(&self) -> bool {
649        self.config.auto_connect()
650    }
651
652    fn accept_connections(&self) -> bool {
653        self.config.accept_connections()
654    }
655
656    fn close_connection(&self, _addr: &TransportAddr) {
657        // use close_connection_async()
658    }
659}
660
661// ============================================================================
662// Tests
663// ============================================================================
664
665#[cfg(test)]
666mod tests;