Skip to content

Commit d8142f1

Browse files
fix(ble): refuse to operate on unbonded LE links
Per CTAP 2.2 §11.4, BLE FIDO authenticator traffic MUST run on a bonded LE Secure Connections link. The previous connect path issued FIDO operations without verifying bonding state, so a session that fell back to an unauthenticated link would proceed in violation of the spec. Adds a pairing module that, on Linux, queries org.bluez.Device1.Paired and org.bluez.Device1.Bonded over DBus and refuses to proceed if either is false. The DBus call is dispatched on spawn_blocking so it doesn't stall the tokio runtime. On non-bluez backends or when DBus is unreachable the check falls back gracefully: macOS and Windows enforce bonding at the OS level for authenticated GATT characteristics, so deferring there is acceptable. The library itself cannot trigger pairing; the user is expected to pair the device beforehand (e.g. via bluetoothctl). manager::connect calls enforce_bonded after establishing the link and before discovering services.
1 parent 0633364 commit d8142f1

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use uuid::Uuid;
1111

1212
use super::device::FidoEndpoints;
1313
use super::gatt::get_gatt_characteristic;
14+
use super::pairing::enforce_bonded;
1415
use super::{Connection, Error, FidoDevice};
1516
use crate::fido::{FidoProtocol, FidoRevision};
1617

@@ -207,8 +208,8 @@ pub async fn supported_fido_revisions(
207208
Ok(supported)
208209
}
209210

210-
/// Connect, discover FIDO services on this device, and
211-
/// select the FIDO revision to be used.
211+
/// Connect, discover FIDO services on this device, and select the FIDO
212+
/// revision to be used. Refuses unbonded LE links (CTAP 2.2 §11.4).
212213
pub async fn connect(
213214
peripheral: &Peripheral,
214215
revision: &FidoRevision,
@@ -217,6 +218,7 @@ pub async fn connect(
217218
.connect()
218219
.await
219220
.or(Err(Error::ConnectionFailed))?;
221+
enforce_bonded(peripheral).await?;
220222
peripheral
221223
.discover_services()
222224
.await

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod device;
33
pub mod error;
44
pub mod gatt;
55
pub mod manager;
6+
pub(crate) mod pairing;
67

78
pub use connection::Connection;
89
pub use device::FidoDevice;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//! Bonding enforcement for BLE FIDO authenticators.
2+
//!
3+
//! CTAP 2.2 §11.4 requires the platform-authenticator BLE link to be
4+
//! bonded with LE Secure Connections. btleplug doesn't surface bonding
5+
//! state, so on Linux we query bluez's `org.bluez.Device1.{Paired,Bonded}`
6+
//! directly. Pairing itself is the OS's responsibility (e.g.
7+
//! `bluetoothctl pair <ADDR>`); this module only verifies the link.
8+
9+
use btleplug::api::{BDAddr, Peripheral as _};
10+
use btleplug::platform::Peripheral;
11+
use tracing::{debug, info, warn};
12+
13+
use super::Error;
14+
15+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16+
pub(crate) enum BondingState {
17+
Bonded,
18+
NotBonded,
19+
/// Bonding state could not be determined (non-bluez backend or DBus
20+
/// unreachable); the caller decides whether to proceed.
21+
Unknown,
22+
}
23+
24+
/// Reads `Paired` and `Bonded` from bluez DBus for `peripheral`.
25+
pub(crate) async fn check_bonded(peripheral: &Peripheral) -> BondingState {
26+
let address = peripheral.address();
27+
debug!(?address, "Checking bonded state via bluez DBus");
28+
29+
let result = tokio::task::spawn_blocking(move || query_bluez_bonded(address)).await;
30+
31+
match result {
32+
Ok(Ok((paired, bonded))) => {
33+
info!(?address, paired, bonded, "bluez bonding state");
34+
if paired && bonded {
35+
BondingState::Bonded
36+
} else {
37+
BondingState::NotBonded
38+
}
39+
}
40+
Ok(Err(e)) => {
41+
warn!(?address, error = ?e, "Could not query bluez bonding state");
42+
BondingState::Unknown
43+
}
44+
Err(e) => {
45+
warn!(error = ?e, "bluez bonding query task panicked");
46+
BondingState::Unknown
47+
}
48+
}
49+
}
50+
51+
/// Returns `Err(ConnectionFailed)` when the device is reachable but
52+
/// explicitly not bonded; falls through on `Unknown`.
53+
pub(crate) async fn enforce_bonded(peripheral: &Peripheral) -> Result<(), Error> {
54+
match check_bonded(peripheral).await {
55+
BondingState::Bonded => Ok(()),
56+
BondingState::Unknown => {
57+
warn!(
58+
"Could not verify LE Secure Connections bonding via bluez; \
59+
proceeding under OS pairing enforcement"
60+
);
61+
Ok(())
62+
}
63+
BondingState::NotBonded => {
64+
warn!(
65+
"BLE FIDO authenticator is not bonded with LE Secure Connections; \
66+
CTAP 2.2 §11.4 requires bonding. Pair the device via the OS \
67+
(e.g. `bluetoothctl pair <ADDR>`) before retrying."
68+
);
69+
Err(Error::ConnectionFailed)
70+
}
71+
}
72+
}
73+
74+
/// btleplug doesn't expose the adapter index, so we walk the bluez
75+
/// ObjectManager tree and match the first device with this address.
76+
fn query_bluez_bonded(address: BDAddr) -> Result<(bool, bool), String> {
77+
use dbus::arg::{PropMap, RefArg};
78+
use dbus::blocking::stdintf::org_freedesktop_dbus::ObjectManager;
79+
use dbus::blocking::{Connection, Proxy};
80+
use std::time::Duration as StdDuration;
81+
82+
let conn = Connection::new_system().map_err(|e| format!("dbus connect: {e}"))?;
83+
let manager = Proxy::new("org.bluez", "/", StdDuration::from_secs(2), &conn);
84+
let objects = manager
85+
.get_managed_objects()
86+
.map_err(|e| format!("GetManagedObjects: {e}"))?;
87+
88+
let mac_lower = format!("{:x}", address);
89+
let dev_segment = format!("dev_{}", mac_lower.replace(':', "_").to_uppercase());
90+
91+
for (path, interfaces) in objects {
92+
let path_str = path.to_string();
93+
if !path_str.starts_with("/org/bluez/") || !path_str.ends_with(&dev_segment) {
94+
continue;
95+
}
96+
let Some(device_props): Option<&PropMap> = interfaces.get("org.bluez.Device1") else {
97+
continue;
98+
};
99+
let paired = device_props
100+
.get("Paired")
101+
.and_then(|v| v.0.as_any().downcast_ref::<bool>().copied())
102+
.unwrap_or(false);
103+
let bonded = device_props
104+
.get("Bonded")
105+
.and_then(|v| v.0.as_any().downcast_ref::<bool>().copied())
106+
.unwrap_or(false);
107+
return Ok((paired, bonded));
108+
}
109+
110+
Err(format!("device {address} not found in bluez ObjectManager"))
111+
}

0 commit comments

Comments
 (0)