Skip to content

Commit 201d527

Browse files
fix(ble): notifications, write-with-response, LE secure connections check (#202)
- Consume fidoStatus notifications instead of GATT Read - Use Write Request for spec-declared Write characteristics - Refuse unbonded LE links per CTAP 2.2 §11.4.3 - Enumerate undiscovered peripherals - Add webauthn_ble example
1 parent 95db65c commit 201d527

9 files changed

Lines changed: 432 additions & 36 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ WebAuthn examples consume and emit JSON per the [WebAuthn IDL][webauthn].
7070
| Transport | FIDO U2F | WebAuthn (FIDO2) |
7171
| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
7272
| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --example webauthn_hid` |
73-
| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | |
73+
| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --example webauthn_ble` |
7474
| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`<br>`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc --example webauthn_nfc`<br>`cargo run --features nfc-backend-libnfc --example webauthn_nfc` |
7575
| **Hybrid (caBLE v2)** || `cargo run --example webauthn_cable` |
7676

libwebauthn/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ required-features = ["nfc"]
116116
name = "webauthn_hid"
117117
path = "examples/ceremony/webauthn_hid.rs"
118118

119+
[[example]]
120+
name = "webauthn_ble"
121+
path = "examples/ceremony/webauthn_ble.rs"
122+
119123
[[example]]
120124
name = "webauthn_nfc"
121125
path = "examples/ceremony/webauthn_nfc.rs"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
use std::error::Error;
2+
3+
use libwebauthn::ops::webauthn::{
4+
DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
5+
WebAuthnIDL as _, WebAuthnIDLResponse as _,
6+
};
7+
use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor;
8+
use libwebauthn::transport::ble::list_devices;
9+
use libwebauthn::transport::{Channel as _, Device};
10+
use libwebauthn::webauthn::WebAuthn;
11+
12+
#[path = "../common/mod.rs"]
13+
mod common;
14+
15+
#[tokio::main]
16+
pub async fn main() -> Result<(), Box<dyn Error>> {
17+
common::setup_logging();
18+
19+
let devices = list_devices().await?;
20+
println!("Devices found: {:?}", devices);
21+
22+
for mut device in devices {
23+
println!("Selected BLE authenticator: {}", &device);
24+
let mut channel = device.channel().await?;
25+
26+
let request_origin: RequestOrigin =
27+
"https://example.org".try_into().expect("Invalid origin");
28+
let psl = DatFilePublicSuffixList::from_system_file().expect(
29+
"PSL not available; install the publicsuffix-list package or pass an explicit path",
30+
);
31+
let request_json = r#"
32+
{
33+
"rp": {
34+
"id": "example.org",
35+
"name": "Example Relying Party"
36+
},
37+
"user": {
38+
"id": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg",
39+
"name": "Mario Rossi",
40+
"displayName": "Mario Rossi"
41+
},
42+
"challenge": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg",
43+
"pubKeyCredParams": [
44+
{"type": "public-key", "alg": -7}
45+
],
46+
"timeout": 60000,
47+
"excludeCredentials": [],
48+
"authenticatorSelection": {
49+
"residentKey": "discouraged",
50+
"userVerification": "preferred"
51+
},
52+
"attestation": "none"
53+
}
54+
"#;
55+
let make_credentials_request: MakeCredentialRequest =
56+
MakeCredentialRequest::from_json(&request_origin, &psl, request_json)
57+
.expect("Failed to parse request JSON");
58+
println!(
59+
"WebAuthn MakeCredential request: {:?}",
60+
make_credentials_request
61+
);
62+
63+
let state_recv = channel.get_ux_update_receiver();
64+
tokio::spawn(common::handle_uv_updates(state_recv));
65+
66+
let response =
67+
retry_user_errors!(channel.webauthn_make_credential(&make_credentials_request))
68+
.unwrap();
69+
println!("WebAuthn MakeCredential response: {:?}", response);
70+
71+
match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) {
72+
Ok(response_json) => {
73+
println!(
74+
"WebAuthn MakeCredential response (JSON):\n{}",
75+
response_json
76+
);
77+
}
78+
Err(e) => {
79+
eprintln!("Failed to serialize MakeCredential response: {:?}", e);
80+
}
81+
}
82+
83+
let cred: Ctap2PublicKeyCredentialDescriptor =
84+
(&response.authenticator_data).try_into().unwrap();
85+
let cred_id_b64 = base64_url::encode(cred.id.as_ref());
86+
let request_json = format!(
87+
r#"
88+
{{
89+
"challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
90+
"timeout": 30000,
91+
"rpId": "example.org",
92+
"userVerification": "discouraged",
93+
"allowCredentials": [
94+
{{"id": "{cred_id_b64}", "type": "public-key", "transports": ["ble"]}}
95+
]
96+
}}
97+
"#
98+
);
99+
let get_assertion: GetAssertionRequest =
100+
GetAssertionRequest::from_json(&request_origin, &psl, &request_json)
101+
.expect("Failed to parse request JSON");
102+
println!("WebAuthn GetAssertion request: {:?}", get_assertion);
103+
104+
let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap();
105+
println!("WebAuthn GetAssertion response: {:?}", response);
106+
107+
for assertion in &response.assertions {
108+
match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) {
109+
Ok(assertion_json) => {
110+
println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json);
111+
}
112+
Err(e) => {
113+
eprintln!("Failed to serialize GetAssertion response: {:?}", e);
114+
}
115+
}
116+
}
117+
}
118+
119+
Ok(())
120+
}

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

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
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 _, WriteType};
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;
15+
use super::gatt::write_type_for;
916
use super::Error;
1017
use crate::fido::FidoRevision;
1118
use crate::transport::ble::framing::{
1219
BleCommand, BleFrame as Frame, BleFrameParser, BleFrameParserResult,
1320
};
1421

15-
#[derive(Debug, Clone)]
22+
type NotificationStream = Pin<Box<dyn Stream<Item = ValueNotification> + Send>>;
23+
24+
#[derive(Clone)]
1625
pub struct Connection {
1726
pub peripheral: Peripheral,
1827
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+
}
1940
}
2041

2142
impl Connection {
@@ -24,9 +45,26 @@ impl Connection {
2445
services: &FidoEndpoints,
2546
revision: &FidoRevision,
2647
) -> 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+
2764
let connection = Self {
2865
peripheral: peripheral.to_owned(),
2966
services: services.clone(),
67+
notifications: Arc::new(Mutex::new(notifications)),
3068
};
3169
connection.select_fido_revision(revision).await?;
3270
Ok(connection)
@@ -60,16 +98,14 @@ impl Connection {
6098
.fragments(max_fragment_size)
6199
.or(Err(Error::InvalidFraming))?;
62100

101+
let write_type = write_type_for(&self.services.control_point);
102+
63103
for (i, fragment) in fragments.iter().enumerate() {
64104
debug!({ fragment = i, len = fragment.len() }, "Sending fragment");
65105
trace!(?fragment);
66106

67107
self.peripheral
68-
.write(
69-
&self.services.control_point,
70-
fragment,
71-
WriteType::WithoutResponse,
72-
)
108+
.write(&self.services.control_point, fragment, write_type)
73109
.await
74110
.or(Err(Error::OperationFailed))?;
75111
}
@@ -79,25 +115,63 @@ impl Connection {
79115

80116
pub(crate) async fn select_fido_revision(&self, revision: &FidoRevision) -> Result<(), Error> {
81117
let ack: u8 = *revision as u8;
118+
let write_type = write_type_for(&self.services.service_revision_bitfield);
82119
self.peripheral
83-
.write(
84-
&self.services.service_revision_bitfield,
85-
&[ack],
86-
WriteType::WithoutResponse,
87-
)
120+
.write(&self.services.service_revision_bitfield, &[ack], write_type)
88121
.await
89122
.or(Err(Error::OperationFailed))?;
90123

91124
info!(?revision, "Successfully selected FIDO revision");
92125
Ok(())
93126
}
94127

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+
95149
#[instrument(skip_all)]
96-
pub async fn frame_recv(&self) -> Result<Frame, Error> {
150+
pub async fn frame_recv(&self, op_timeout: Duration) -> Result<Frame, Error> {
97151
let mut parser = BleFrameParser::new();
152+
let mut stream = self.notifications.lock().await;
98153

99154
loop {
100-
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+
101175
debug!("Received fragment");
102176
trace!(?fragment);
103177

@@ -133,13 +207,7 @@ impl Connection {
133207
}
134208
}
135209

136-
async fn receive_fragment(&self) -> Result<Vec<u8>, Error> {
137-
self.peripheral
138-
.read(&self.services.status)
139-
.await
140-
.or(Err(Error::OperationFailed))
141-
}
142-
210+
/// Enables notifications on `fidoStatus`. Idempotent.
143211
pub async fn subscribe(&self) -> Result<(), Error> {
144212
self.peripheral
145213
.subscribe(&self.services.status)

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

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use btleplug::api::{Characteristic, Peripheral as _};
1+
use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType};
22
use btleplug::platform::Peripheral;
33
use uuid::Uuid;
44

@@ -15,3 +15,67 @@ pub fn get_gatt_characteristic(
1515
.map(ToOwned::to_owned)
1616
.ok_or(Error::ConnectionFailed)
1717
}
18+
19+
/// Picks a `WriteType` from a characteristic's advertised GATT properties.
20+
///
21+
/// `fidoControlPoint` and `fidoServiceRevisionBitfield` are Write
22+
/// characteristics per CTAP 2.2 §11.4; only downgrade to WithoutResponse
23+
/// when that is the sole property advertised.
24+
pub fn write_type_for(characteristic: &Characteristic) -> WriteType {
25+
if characteristic.properties.contains(CharPropFlags::WRITE) {
26+
WriteType::WithResponse
27+
} else if characteristic
28+
.properties
29+
.contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
30+
{
31+
WriteType::WithoutResponse
32+
} else {
33+
WriteType::WithResponse
34+
}
35+
}
36+
37+
#[cfg(test)]
38+
mod tests {
39+
use std::collections::BTreeSet;
40+
41+
use super::*;
42+
43+
fn make_characteristic(properties: CharPropFlags) -> Characteristic {
44+
Characteristic {
45+
uuid: Uuid::nil(),
46+
service_uuid: Uuid::nil(),
47+
properties,
48+
descriptors: BTreeSet::new(),
49+
}
50+
}
51+
52+
#[test]
53+
fn write_type_prefers_with_response_when_write_property_set() {
54+
let c = make_characteristic(CharPropFlags::WRITE);
55+
assert_eq!(write_type_for(&c), WriteType::WithResponse);
56+
}
57+
58+
#[test]
59+
fn write_type_uses_without_response_when_only_write_without_response() {
60+
let c = make_characteristic(CharPropFlags::WRITE_WITHOUT_RESPONSE);
61+
assert_eq!(write_type_for(&c), WriteType::WithoutResponse);
62+
}
63+
64+
#[test]
65+
fn write_type_prefers_with_response_when_both_properties_set() {
66+
let c = make_characteristic(CharPropFlags::WRITE | CharPropFlags::WRITE_WITHOUT_RESPONSE);
67+
assert_eq!(write_type_for(&c), WriteType::WithResponse);
68+
}
69+
70+
#[test]
71+
fn write_type_defaults_to_with_response_when_no_property_set() {
72+
let c = make_characteristic(CharPropFlags::empty());
73+
assert_eq!(write_type_for(&c), WriteType::WithResponse);
74+
}
75+
76+
#[test]
77+
fn write_type_ignores_unrelated_properties() {
78+
let c = make_characteristic(CharPropFlags::READ | CharPropFlags::NOTIFY);
79+
assert_eq!(write_type_for(&c), WriteType::WithResponse);
80+
}
81+
}

0 commit comments

Comments
 (0)