webauthn_authenticator_rs/bluetooth/
mod.rs

1//! [BluetoothTransport] communicates with a FIDO token over Bluetooth Low
2//! Energy, using [btleplug].
3//!
4//! This module should work on most platforms with Bluetooth Low Energy support,
5//! provided that the user has permissions.
6//!
7//! ## Warning
8//!
9//! There are [API design issues][0] with [Transport] which make
10//! [BluetoothTransport] **extremely** flaky and timing sensitive. These have
11//! been partially addressed, but there is still some way to go.
12//!
13//! The [long term goal][0] is that this API (and its UI) will become as easy to
14//! use as Windows WebAuthn API, but it's not there just yet.
15//!
16//! [0]: https://github.com/kanidm/webauthn-rs/issues/214
17//!
18//! ## caBLE support
19//!
20//! To use a caBLE / hybrid authenticator, use the [cable][crate::cable] module
21//! (avaliable with `--features cable`) instead.
22//!
23//! ## Linux support
24//!
25//! Seems to be extremely flakey.
26//!
27//! ## macOS support
28//!
29//! Works fine.
30//!
31//! Non-paired (but discoverable) Bluetooth FIDO tokens do not appear in the
32//! System Settings Bluetooth pane – it can only be triggered by an application
33//! attempting to connect to an authenticator.
34//!
35//! This will attempt to connect to any nearby FIDO token.
36//!
37//! ## Windows support
38//!
39//! Windows' WebAuthn API (on Windows 10 build 1903 and later) blocks
40//! non-Administrator access to BTLE FIDO tokens, and will return "permission
41//! denied" errors when accessed via normal Bluetooth APIs. This does not impact
42//! use of caBLE authenticators.
43//!
44//! Use [Win10][crate::win10::Win10] (available with `--features win10`) on
45//! Windows instead.
46//!
47//! You'll need to manually pair your authenticator in Device Manager before
48//! using it with this or Windows' WebAuthn API.
49use std::{
50    collections::{HashMap, HashSet},
51    ops::RangeInclusive,
52    pin::Pin,
53    time::{Duration, Instant},
54};
55
56#[cfg(doc)]
57use crate::stubs::*;
58
59use async_trait::async_trait;
60use btleplug::{
61    api::{
62        bleuuid::uuid_from_u16, Central, CentralEvent, Characteristic, Manager as _,
63        Peripheral as _, ScanFilter, WriteType,
64    },
65    platform::{Manager, Peripheral, PeripheralId},
66};
67use futures::{executor::block_on, stream::BoxStream, Stream, StreamExt};
68use tokio::{sync::mpsc, task::spawn};
69use tokio_stream::wrappers::ReceiverStream;
70use uuid::{uuid, Uuid};
71use webauthn_rs_proto::AuthenticatorTransport;
72
73use crate::{
74    error::WebauthnCError,
75    transport::{
76        types::{
77            CBORResponse, KeepAliveStatus, Response, U2FError, BTLE_CANCEL, BTLE_KEEPALIVE,
78            U2FHID_ERROR, U2FHID_MSG, U2FHID_PING,
79        },
80        Token, TokenEvent, Transport, TYPE_INIT,
81    },
82    ui::UiCallback,
83};
84
85use self::framing::{BtleFrame, BtleFrameIterator};
86
87mod framing;
88
89/// The FIDO Bluetooth GATT [Service] [Uuid].
90///
91/// Reference: [Bluetooth Assigned Numbers][], Section 3.10 (SDO Services)
92///
93/// [Bluetooth Assigned Numbers]: https://www.bluetooth.com/specifications/assigned-numbers/
94/// [Service]: btleplug::api::Service
95const FIDO_GATT_SERVICE: Uuid = uuid_from_u16(0xfffd);
96
97/// FIDO Control Point [Characteristic] [Uuid].
98///
99/// This is a write-only command buffer for the initiator.
100const FIDO_CONTROL_POINT: Uuid = uuid!("F1D0FFF1-DEAA-ECEE-B42F-C9BA7ED623BB");
101
102/// FIDO Status [Characteristic] [Uuid].
103///
104/// The authenticator sends notifications to respond to commands sent to
105/// [FIDO_CONTROL_POINT].
106const FIDO_STATUS: Uuid = uuid!("F1D0FFF2-DEAA-ECEE-B42F-C9BA7ED623BB");
107
108/// FIDO Control Point Length [Characteristic] [Uuid].
109///
110/// This is a read-only value in [VALID_MTU_RANGE] which indicates the MTU of
111/// the [FIDO_CONTROL_POINT] and [FIDO_STATUS] [Characteristic]s.
112const FIDO_CONTROL_POINT_LENGTH: Uuid = uuid!("F1D0FFF3-DEAA-ECEE-B42F-C9BA7ED623BB");
113
114/// FIDO Service Revision Bitfield [Characteristic] [Uuid].
115///
116/// When read by the initiator, the authenticator sends which protocol versions
117/// are supported as a bitfield, and then the initiator writes a single bit
118/// indicating which protocol it will use.
119///
120/// This is not present on U2F 1.0 authenticators.
121const FIDO_SERVICE_REVISION_BITFIELD: Uuid = uuid!("F1D0FFF4-DEAA-ECEE-B42F-C9BA7ED623BB");
122
123/// Valid MTU range for [FIDO_CONTROL_POINT_LENGTH].
124const VALID_MTU_RANGE: RangeInclusive<usize> = 20..=512;
125
126/// Bitfield value in [FIDO_SERVICE_REVISION_BITFIELD] to indicate an
127/// authenticator supports CTAP2.
128const SERVICE_REVISION_CTAP2: u8 = 0x20;
129
130pub struct BluetoothDeviceWatcher {
131    // transport: &'a BluetoothTransport,
132    stream: ReceiverStream<TokenEvent<BluetoothToken>>,
133}
134
135impl BluetoothDeviceWatcher {
136    async fn new(
137        transport: &BluetoothTransport,
138        debounce: Duration,
139    ) -> Result<BluetoothDeviceWatcher, WebauthnCError> {
140        let (tx, rx) = mpsc::channel(16);
141        let stream = ReceiverStream::from(rx);
142
143        let adapters = transport.manager.adapters().await?;
144        let adapter = adapters
145            .into_iter()
146            .next()
147            .ok_or(WebauthnCError::NoBluetoothAdapter)?;
148
149        let filter = ScanFilter {
150            services: vec![FIDO_GATT_SERVICE],
151        };
152        adapter.start_scan(filter).await?;
153
154        let mut events = adapter.events().await?;
155        if let Err(e) = tx.send(TokenEvent::EnumerationComplete).await {
156            error!("could not send Bluetooth EnumerationComplete: {e:?}");
157            return Err(WebauthnCError::Internal);
158        }
159
160        spawn(async move {
161            // We need to track recently connected devices so that we don't get
162            // stuck in loops. There's also Hideez which continues transmitting
163            // advertisements for 30 seconds after use, even when the LED is
164            // off...
165            let mut recents: HashMap<PeripheralId, Instant> = HashMap::new();
166            let mut connected = HashSet::new();
167            while let Some(event) = events.next().await {
168                if tx.is_closed() {
169                    break;
170                }
171                match event {
172                    CentralEvent::DeviceConnected(id) => {
173                        trace!("device connected: {id:?}");
174                        let peripheral = match adapter.peripheral(&id).await {
175                            Ok(p) => p,
176                            Err(e) => {
177                                error!("could not get info for BTLE peripheral {id:?}: {e:?}");
178                                continue;
179                            }
180                        };
181
182                        let properties = match peripheral.properties().await {
183                            Ok(Some(p)) => p,
184                            Ok(None) => {
185                                error!(
186                                    "no properties available for BTLE peripheral {id:?}, ignoring"
187                                );
188                                continue;
189                            }
190                            Err(e) => {
191                                error!(
192                                    "could not get properties for BTLE peripheral {id:?}: {e:?}"
193                                );
194                                continue;
195                            }
196                        };
197
198                        if !properties.services.contains(&FIDO_GATT_SERVICE) {
199                            trace!("BTLE peripheral {id:?} is not a FIDO token, skipping");
200                            continue;
201                        }
202
203                        connected.insert(id);
204                        if tx
205                            .send(TokenEvent::Added(BluetoothToken::new(peripheral)))
206                            .await
207                            .is_err()
208                        {
209                            // channel lost!
210                            break;
211                        }
212                    }
213
214                    CentralEvent::DeviceDisconnected(id) => {
215                        trace!("device disconnected: {id:?}");
216                        if !connected.remove(&id) {
217                            // Don't notify about the same device twice
218                            continue;
219                        }
220
221                        if tx.send(TokenEvent::Removed(id)).await.is_err() {
222                            // channel lost!
223                            break;
224                        }
225                    }
226
227                    CentralEvent::DeviceDiscovered(id) => {
228                        trace!("device discovered: {id:?}");
229                        if let Some(last_seen) = recents.get(&id) {
230                            if last_seen.elapsed() < debounce {
231                                trace!("ignoring recently-seen device: {id:?}");
232                                recents.insert(id, Instant::now());
233                                continue;
234                            }
235
236                            recents.remove(&id);
237                        }
238
239                        let peripheral = match adapter.peripheral(&id).await {
240                            Ok(p) => p,
241                            Err(e) => {
242                                error!("could not get info for BTLE peripheral {id:?}: {e:?}");
243                                continue;
244                            }
245                        };
246
247                        let properties = match peripheral.properties().await {
248                            Ok(Some(p)) => p,
249                            Ok(None) => {
250                                error!(
251                                    "no properties available for BTLE peripheral {id:?}, ignoring"
252                                );
253                                continue;
254                            }
255                            Err(e) => {
256                                error!(
257                                    "could not get properties for BTLE peripheral {id:?}: {e:?}"
258                                );
259                                continue;
260                            }
261                        };
262
263                        trace!("services: {:?}", properties.services);
264                        // Hideez key seems to lack services on rediscovery?
265                        if !properties.services.is_empty()
266                            && !properties.services.contains(&FIDO_GATT_SERVICE)
267                        {
268                            trace!("BTLE peripheral {id:?} is not a FIDO token, skipping");
269                            continue;
270                        }
271
272                        trace!("device name: {:?}", properties.local_name);
273                        recents.insert(id, Instant::now());
274                        if let Err(e) = peripheral.connect().await {
275                            error!("could not connect: {e:?}");
276                        }
277                    }
278
279                    CentralEvent::ServicesAdvertisement { id, services } => {
280                        // macOS doesn't fire another DeviceDiscovered event if
281                        // a device goes away and then comes back.
282                        trace!("services advertisement: {id:?} {services:?}");
283                        if !services.contains(&FIDO_GATT_SERVICE) {
284                            trace!("BTLE peripheral {id:?} is not a FIDO token, skipping");
285                            continue;
286                        }
287
288                        let peripheral = match adapter.peripheral(&id).await {
289                            Ok(p) => p,
290                            Err(e) => {
291                                error!("could not get info for BTLE peripheral {id:?}: {e:?}");
292                                continue;
293                            }
294                        };
295
296                        if peripheral.is_connected().await? {
297                            trace!("ignoring connected peripheral: {id:?}");
298                            continue;
299                        }
300
301                        if let Some(last_seen) = recents.get(&id) {
302                            if last_seen.elapsed() < debounce {
303                                trace!("ignoring recently-seen device: {id:?}");
304                                recents.insert(id, Instant::now());
305                                continue;
306                            }
307
308                            recents.remove(&id);
309                        }
310
311                        let properties = match peripheral.properties().await {
312                            Ok(Some(p)) => p,
313                            Ok(None) => {
314                                error!(
315                                    "no properties available for BTLE peripheral {id:?}, ignoring"
316                                );
317                                continue;
318                            }
319                            Err(e) => {
320                                error!(
321                                    "could not get properties for BTLE peripheral {id:?}: {e:?}"
322                                );
323                                continue;
324                            }
325                        };
326
327                        trace!("device name: {:?}", properties.local_name);
328                        recents.insert(id, Instant::now());
329                        if let Err(e) = peripheral.connect().await {
330                            error!("could not connect: {e:?}");
331                        }
332                    }
333
334                    _ => (),
335                }
336            }
337
338            adapter.stop_scan().await
339        });
340
341        Ok(Self { stream })
342    }
343}
344
345impl Stream for BluetoothDeviceWatcher {
346    type Item = TokenEvent<BluetoothToken>;
347
348    fn poll_next(
349        self: Pin<&mut Self>,
350        cx: &mut std::task::Context<'_>,
351    ) -> std::task::Poll<Option<Self::Item>> {
352        ReceiverStream::poll_next(Pin::new(&mut Pin::get_mut(self).stream), cx)
353    }
354}
355
356#[derive(Debug)]
357pub struct BluetoothTransport {
358    manager: Manager,
359}
360
361impl BluetoothTransport {
362    /// Creates a new instance of the Bluetooth Low Energy scanner.
363    pub async fn new() -> Result<Self, WebauthnCError> {
364        Ok(Self {
365            manager: Manager::new().await?,
366        })
367    }
368}
369
370#[async_trait]
371impl<'b> Transport<'b> for BluetoothTransport {
372    type Token = BluetoothToken;
373
374    /// ## Important
375    ///
376    /// [`tokens()`] is unsupported for [BluetoothTransport], as BTLE
377    /// authenticator connections are very short-lived and timing sensitive.
378    ///
379    /// This method will always return an empty `Vec` of devices.
380    ///
381    /// Use [`watch()`][] instead.
382    ///
383    /// [`tokens()`]: BluetoothTransport::tokens
384    /// [`watch()`]: BluetoothTransport::watch
385    async fn tokens(&self) -> Result<Vec<Self::Token>, WebauthnCError> {
386        warn!("tokens() is not supported for Bluetooth devices, use watch()");
387        Ok(vec![])
388    }
389
390    /// Watches for and connects to nearby Bluetooth Low Energy authenticators.
391    ///
392    /// ## Note
393    ///
394    /// Due to the nature of the Bluetooth Low Energy transport,
395    /// [BluetoothTransport] immediately emits a
396    /// [TokenEvent::EnumerationComplete] event as soon as it starts.
397    async fn watch(&self) -> Result<BoxStream<TokenEvent<Self::Token>>, WebauthnCError> {
398        trace!("Scanning for BTLE tokens");
399        let stream = BluetoothDeviceWatcher::new(self, Duration::from_secs(10)).await?;
400        Ok(Box::pin(stream))
401    }
402}
403
404#[derive(Debug)]
405pub struct BluetoothToken {
406    device: Peripheral,
407    mtu: usize,
408    control_point: Option<Characteristic>,
409}
410
411impl BluetoothToken {
412    fn new(device: Peripheral) -> Self {
413        BluetoothToken {
414            device,
415            mtu: 0,
416            control_point: None,
417        }
418    }
419
420    /// Gets the current MTU for the authenticator.
421    ///
422    /// Returns [WebauthnCError::UnexpectedState] if it is out of range.
423    #[inline]
424    fn checked_mtu(&self) -> Result<usize, WebauthnCError> {
425        if !VALID_MTU_RANGE.contains(&self.mtu) {
426            Err(WebauthnCError::UnexpectedState)
427        } else {
428            Ok(self.mtu)
429        }
430    }
431
432    /// Sends a single [BtleFrame] to the device, without fragmentation.
433    async fn send_one(&self, frame: BtleFrame) -> Result<(), WebauthnCError> {
434        let d = frame.as_vec(self.checked_mtu()?)?;
435        trace!(">>> {}", hex::encode(&d));
436        self.device
437            .write(
438                self.control_point
439                    .as_ref()
440                    .ok_or(WebauthnCError::UnexpectedState)?,
441                &d,
442                WriteType::WithResponse,
443            )
444            .await?;
445        Ok(())
446    }
447
448    /// Sends a [BtleFrame] to the device, fragmenting the message to fit
449    /// within the BTLE MTU.
450    async fn send(&self, frame: &BtleFrame) -> Result<(), WebauthnCError> {
451        for f in BtleFrameIterator::new(frame, self.checked_mtu()?)? {
452            self.send_one(f).await?;
453        }
454        Ok(())
455    }
456}
457
458#[async_trait]
459impl Token for BluetoothToken {
460    type Id = PeripheralId;
461
462    async fn transmit_raw<U>(&mut self, cmd: &[u8], ui: &U) -> Result<Vec<u8>, WebauthnCError>
463    where
464        U: UiCallback,
465    {
466        // We need to get the notification stream for each command, because
467        // otherwise could lose messages while waiting for a response. This
468        // provides an asynchronous stream of events as they come in.
469        let mut stream = self.device.notifications().await?;
470
471        // In CTAP2 mode, `U2FHID_MSG` is a raw CBOR message.
472        let cmd = BtleFrame {
473            cmd: U2FHID_MSG,
474            len: cmd.len() as u16,
475            data: cmd.to_vec(),
476        };
477        self.send(&cmd).await?;
478
479        // Get a response, checking for keep-alive
480        let resp = loop {
481            let mut t = 0usize;
482            let mut s = 0usize;
483            let mut c = Vec::new();
484
485            while let Some(data) = stream.next().await {
486                trace!("<<< {}", hex::encode(&data.value));
487                if data.uuid != FIDO_STATUS {
488                    trace!("Ignoring notification for unknown UUID: {:?}", data.uuid);
489                    continue;
490                }
491
492                let frame = BtleFrame::try_from(data.value.as_slice())?;
493                if frame.cmd >= TYPE_INIT {
494                    if t == 0 {
495                        // Initial frame contains length
496                        t = usize::from(frame.len);
497                    } else {
498                        error!("Unexpected initial frame");
499                        return Err(WebauthnCError::Unknown);
500                    }
501                } else if t == 0 {
502                    error!("Unexpected continuation frame");
503                    return Err(WebauthnCError::Unknown);
504                }
505
506                s += frame.data.len();
507                c.push(frame);
508
509                if s >= t {
510                    // We have all the chunks we expected.
511                    break;
512                }
513            }
514
515            if s < t {
516                error!("Stream stopped before getting complete message");
517                return Err(WebauthnCError::Unknown);
518            }
519
520            let f: BtleFrame = c.iter().sum();
521            trace!("recv done: {f:?}");
522            let resp = Response::try_from(&f)?;
523            trace!("Response: {resp:?}");
524
525            if let Response::KeepAlive(r) = resp {
526                trace!("waiting for {:?}", r);
527                match r {
528                    KeepAliveStatus::UserPresenceNeeded => ui.request_touch(),
529                    KeepAliveStatus::Processing => ui.processing(),
530                    _ => (),
531                }
532                // TODO: maybe time out at some point
533                // thread::sleep(Duration::from_millis(100));
534            } else {
535                break resp;
536            }
537        };
538
539        // Get a response
540        match resp {
541            Response::Cbor(c) => {
542                if c.status.is_ok() {
543                    Ok(c.data)
544                } else {
545                    let e = WebauthnCError::Ctap(c.status);
546                    error!("Ctap error: {:?}", e);
547                    Err(e)
548                }
549            }
550            e => {
551                error!("Unhandled response type: {:?}", e);
552                Err(WebauthnCError::Cbor)
553            }
554        }
555    }
556
557    async fn init(&mut self) -> Result<(), WebauthnCError> {
558        if !self.device.is_connected().await? {
559            self.device.connect().await?;
560        }
561
562        self.device.discover_services().await?;
563        let service = self
564            .device
565            .services()
566            .into_iter()
567            .find(|s| s.uuid == FIDO_GATT_SERVICE)
568            .ok_or(WebauthnCError::NotSupported)?;
569
570        // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-errata-20220621.html#ble-protocol-overview
571        // 5. Client checks if the fidoServiceRevisionBitfield characteristic is
572        // present. If so, the client selects a supported version by writing a
573        // value with a single bit set.
574        if let Some(c) = service
575            .characteristics
576            .iter()
577            .find(|c| c.uuid == FIDO_SERVICE_REVISION_BITFIELD)
578        {
579            trace!("Selecting protocol version");
580            if let Some(b) = self.device.read(c).await?.first() {
581                trace!("Service revision bitfield: {b:#08b}");
582                if b & SERVICE_REVISION_CTAP2 == 0 {
583                    error!("Device does not support CTAP2, not supported!");
584                    return Err(WebauthnCError::NotSupported);
585                }
586
587                trace!("Requesting CTAP2");
588                self.device
589                    .write(c, &[SERVICE_REVISION_CTAP2], WriteType::WithResponse)
590                    .await?;
591                trace!("Done");
592            } else {
593                error!("Could not read protocol version");
594                return Err(WebauthnCError::MissingRequiredField);
595            }
596        } else {
597            error!("Device does not support CTAP2, not supported!");
598            return Err(WebauthnCError::NotSupported);
599        }
600
601        // 6. Client reads the fidoControlPointLength characteristic.
602        if let Some(c) = service
603            .characteristics
604            .iter()
605            .find(|c| c.uuid == FIDO_CONTROL_POINT_LENGTH)
606        {
607            let b = self.device.read(c).await?;
608            if b.len() < 2 {
609                return Err(WebauthnCError::MessageTooShort);
610            }
611            self.mtu = u16::from_be_bytes(
612                b[0..2]
613                    .try_into()
614                    .map_err(|_| WebauthnCError::MessageTooShort)?,
615            ) as usize;
616            trace!("Control point length: {}", self.mtu);
617            if self.mtu < 20 || self.mtu > 512 {
618                error!("Control point length must be between 20 and 512 bytes");
619                return Err(WebauthnCError::NotSupported);
620            }
621        } else {
622            error!("No control point length specified!");
623            return Err(WebauthnCError::MissingRequiredField);
624        }
625
626        // 7. Client registers for notifications on the fidoStatus
627        // characteristic.
628        if let Some(c) = service
629            .characteristics
630            .iter()
631            .find(|c| c.uuid == FIDO_STATUS)
632        {
633            self.device.subscribe(c).await?;
634        } else {
635            error!("No status attribute, cannot get responses to commands!");
636            return Err(WebauthnCError::MissingRequiredField);
637        }
638
639        // We want to be able to send some messages later.
640        if let Some(c) = service
641            .characteristics
642            .iter()
643            .find(|c| c.uuid == FIDO_CONTROL_POINT)
644        {
645            self.control_point = Some(c.to_owned());
646        } else {
647            error!("No control point attribute, cannot send commands!");
648            return Err(WebauthnCError::MissingRequiredField);
649        }
650
651        Ok(())
652    }
653
654    async fn close(&mut self) -> Result<(), WebauthnCError> {
655        if self.device.is_connected().await.unwrap_or_default() {
656            self.device.disconnect().await?;
657        }
658        Ok(())
659    }
660
661    fn get_transport(&self) -> AuthenticatorTransport {
662        AuthenticatorTransport::Ble
663    }
664
665    async fn cancel(&mut self) -> Result<(), WebauthnCError> {
666        self.send_one(BtleFrame {
667            cmd: BTLE_CANCEL,
668            len: 0,
669            data: vec![],
670        })
671        .await
672    }
673}
674
675impl Drop for BluetoothToken {
676    fn drop(&mut self) {
677        trace!("dropping");
678        block_on(self.close()).ok();
679    }
680}
681
682/// Parser for a response [BtleFrame].
683///
684/// The frame must be complete (ie: all fragments received) before parsing.
685impl TryFrom<&BtleFrame> for Response {
686    type Error = WebauthnCError;
687
688    fn try_from(f: &BtleFrame) -> Result<Response, WebauthnCError> {
689        if !f.complete() {
690            error!("cannot parse incomplete frame");
691            return Err(WebauthnCError::UnexpectedState);
692        }
693
694        let b = &f.data[..];
695        Ok(match f.cmd {
696            U2FHID_PING => Response::Ping(b.to_vec()),
697            BTLE_KEEPALIVE => Response::KeepAlive(KeepAliveStatus::from(b)),
698            U2FHID_MSG => CBORResponse::try_from(b).map(Response::Cbor)?,
699            U2FHID_ERROR => Response::Error(U2FError::from(b)),
700            _ => {
701                error!("unknown BTLE command: 0x{:02x}", f.cmd,);
702                Response::Unknown
703            }
704        })
705    }
706}