Skip to content

Commit fd28bce

Browse files
fix(ble): consume fidoStatus notifications instead of GATT Read
Per CTAP 2.2 §11.4 the fidoStatus characteristic is Notify-only. The previous receive path called peripheral.read() against it, which bluez rejects with NotPermitted and makes FIDO2-over-BLE non-functional on Linux. Other backends may tolerate the Read by returning stale cached data, which is not real notification-driven framing either way. Connection::new now subscribes to fidoStatus, obtains the peripheral's notification stream, filters it to the fidoStatus UUID, and stores it on the connection. frame_recv awaits the next notification with the caller-supplied operation timeout; on expiry it sends a BleCommand::Cancel on fidoControlPoint (best-effort, WithoutResponse) and returns Timeout. The channel's apdu_recv and cbor_recv now forward their timeout to frame_recv and map Error::Timeout to TransportError::Timeout.
1 parent f16c260 commit fd28bce

2 files changed

Lines changed: 96 additions & 17 deletions

File tree

libwebauthn/src/transport/ble/btleplug/connection.rs

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
use std::io::Cursor as IOCursor;
2+
use std::pin::Pin;
3+
use std::sync::Arc;
4+
use std::time::Duration;
25

3-
use btleplug::api::Peripheral as _;
6+
use btleplug::api::{Peripheral as _, ValueNotification, WriteType};
47
use btleplug::platform::Peripheral;
58
use byteorder::{BigEndian, ReadBytesExt};
9+
use futures::stream::{Stream, StreamExt};
10+
use tokio::sync::Mutex;
11+
use tokio::time::timeout;
612
use tracing::{debug, info, instrument, trace, warn};
713

814
use super::device::FidoEndpoints;
@@ -13,10 +19,24 @@ use crate::transport::ble::framing::{
1319
BleCommand, BleFrame as Frame, BleFrameParser, BleFrameParserResult,
1420
};
1521

16-
#[derive(Debug, Clone)]
22+
type NotificationStream = Pin<Box<dyn Stream<Item = ValueNotification> + Send>>;
23+
24+
#[derive(Clone)]
1725
pub struct Connection {
1826
pub peripheral: Peripheral,
1927
pub services: FidoEndpoints,
28+
/// `fidoStatus` is Notify-only (CTAP 2.2 §11.4); we consume notifications
29+
/// rather than issue GATT Read.
30+
notifications: Arc<Mutex<NotificationStream>>,
31+
}
32+
33+
impl std::fmt::Debug for Connection {
34+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35+
f.debug_struct("Connection")
36+
.field("peripheral", &self.peripheral)
37+
.field("services", &self.services)
38+
.finish_non_exhaustive()
39+
}
2040
}
2141

2242
impl Connection {
@@ -25,9 +45,26 @@ impl Connection {
2545
services: &FidoEndpoints,
2646
revision: &FidoRevision,
2747
) -> Result<Self, Error> {
48+
// Subscribe before opening the stream so early frames aren't dropped.
49+
peripheral
50+
.subscribe(&services.status)
51+
.await
52+
.or(Err(Error::OperationFailed))?;
53+
54+
let status_uuid = services.status.uuid;
55+
let raw_stream = peripheral
56+
.notifications()
57+
.await
58+
.or(Err(Error::OperationFailed))?;
59+
let notifications: NotificationStream = Box::pin(raw_stream.filter(move |n| {
60+
let matches = n.uuid == status_uuid;
61+
async move { matches }
62+
}));
63+
2864
let connection = Self {
2965
peripheral: peripheral.to_owned(),
3066
services: services.clone(),
67+
notifications: Arc::new(Mutex::new(notifications)),
3168
};
3269
connection.select_fido_revision(revision).await?;
3370
Ok(connection)
@@ -88,12 +125,53 @@ impl Connection {
88125
Ok(())
89126
}
90127

128+
/// Sends a best-effort Cancel on `fidoControlPoint` using
129+
/// `WriteType::WithoutResponse` so cancellation never blocks.
130+
async fn send_cancel(&self) -> Result<(), Error> {
131+
let cancel_frame = Frame::new(BleCommand::Cancel, &[]);
132+
let max_fragment_size = self.control_point_length().await.unwrap_or(20);
133+
let fragments = cancel_frame
134+
.fragments(max_fragment_size)
135+
.or(Err(Error::InvalidFraming))?;
136+
for fragment in fragments {
137+
self.peripheral
138+
.write(
139+
&self.services.control_point,
140+
&fragment,
141+
WriteType::WithoutResponse,
142+
)
143+
.await
144+
.or(Err(Error::OperationFailed))?;
145+
}
146+
Ok(())
147+
}
148+
91149
#[instrument(skip_all)]
92-
pub async fn frame_recv(&self) -> Result<Frame, Error> {
150+
pub async fn frame_recv(&self, op_timeout: Duration) -> Result<Frame, Error> {
93151
let mut parser = BleFrameParser::new();
152+
let mut stream = self.notifications.lock().await;
94153

95154
loop {
96-
let fragment = self.receive_fragment().await?;
155+
let fragment = match timeout(op_timeout, stream.next()).await {
156+
Ok(Some(notification)) => notification.value,
157+
Ok(None) => {
158+
warn!("Notification stream ended unexpectedly");
159+
return Err(Error::ConnectionFailed);
160+
}
161+
Err(_) => {
162+
warn!(
163+
?op_timeout,
164+
"Timed out waiting for fidoStatus notification; sending Cancel"
165+
);
166+
// Drop the lock so a late notification doesn't deadlock the cancel.
167+
drop(stream);
168+
if let Err(e) = self.send_cancel().await {
169+
warn!(?e, "Failed to send Cancel after timeout");
170+
}
171+
return Err(Error::Timeout);
172+
}
173+
};
174+
97175
debug!("Received fragment");
98176
trace!(?fragment);
99177

@@ -129,13 +207,7 @@ impl Connection {
129207
}
130208
}
131209

132-
async fn receive_fragment(&self) -> Result<Vec<u8>, Error> {
133-
self.peripheral
134-
.read(&self.services.status)
135-
.await
136-
.or(Err(Error::OperationFailed))
137-
}
138-
210+
/// Enables notifications on `fidoStatus`. Idempotent.
139211
pub async fn subscribe(&self) -> Result<(), Error> {
140212
self.peripheral
141213
.subscribe(&self.services.status)

libwebauthn/src/transport/ble/channel.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,16 @@ impl<'a> Channel for BleChannel<'a> {
100100
}
101101

102102
#[instrument(level = Level::DEBUG, skip_all)]
103-
async fn apdu_recv(&mut self, _timeout: Duration) -> Result<ApduResponse, Error> {
103+
async fn apdu_recv(&mut self, timeout: Duration) -> Result<ApduResponse, Error> {
104104
let response_frame = self
105105
.connection
106-
.frame_recv()
106+
.frame_recv(timeout)
107107
.await
108-
.or(Err(Error::Transport(TransportError::ConnectionFailed)))?;
108+
.map_err(|e| match e {
109+
btleplug::Error::Timeout => Error::Transport(TransportError::Timeout),
110+
_ => Error::Transport(TransportError::ConnectionFailed),
111+
})?;
112+
109113
match response_frame.cmd {
110114
BleCommand::Error => return Err(Error::Transport(TransportError::InvalidFraming)), // Encapsulation layer error
111115
BleCommand::Cancel => return Err(Error::Ctap(CtapError::KeepAliveCancel)),
@@ -143,12 +147,15 @@ impl<'a> Channel for BleChannel<'a> {
143147
}
144148

145149
#[instrument(level = Level::DEBUG, skip_all)]
146-
async fn cbor_recv(&mut self, _timeout: std::time::Duration) -> Result<CborResponse, Error> {
150+
async fn cbor_recv(&mut self, timeout: std::time::Duration) -> Result<CborResponse, Error> {
147151
let response_frame = self
148152
.connection
149-
.frame_recv()
153+
.frame_recv(timeout)
150154
.await
151-
.or(Err(Error::Transport(TransportError::ConnectionFailed)))?;
155+
.map_err(|e| match e {
156+
btleplug::Error::Timeout => Error::Transport(TransportError::Timeout),
157+
_ => Error::Transport(TransportError::ConnectionFailed),
158+
})?;
152159
match response_frame.cmd {
153160
BleCommand::Error => return Err(Error::Transport(TransportError::InvalidFraming)), // Encapsulation layer error
154161
BleCommand::Cancel => return Err(Error::Ctap(CtapError::KeepAliveCancel)),

0 commit comments

Comments
 (0)