diff --git a/Cargo.lock b/Cargo.lock
index 5c00153..71a4383 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -328,6 +328,27 @@ dependencies = [
"objc2",
]
+[[package]]
+name = "bluer"
+version = "0.17.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901"
+dependencies = [
+ "futures",
+ "hex",
+ "libc",
+ "log",
+ "macaddr",
+ "nix",
+ "num-derive",
+ "num-traits",
+ "serde",
+ "serde_json",
+ "strum",
+ "tokio",
+ "uuid",
+]
+
[[package]]
name = "bluez-async"
version = "0.8.2"
@@ -467,6 +488,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
[[package]]
name = "chacha20"
version = "0.9.1"
@@ -1778,6 +1805,7 @@ dependencies = [
"async-trait",
"base64-url",
"bitflags 2.11.1",
+ "bluer",
"btleplug",
"byteorder",
"cbc",
@@ -1928,6 +1956,12 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "macaddr"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8"
+
[[package]]
name = "maplit"
version = "1.0.2"
@@ -2030,6 +2064,18 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.11.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -3100,6 +3146,28 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.117",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
diff --git a/README.md b/README.md
index c10f4c6..7c9e42e 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ _Looking for the D-Bus API proposal?_ Check out [credentialsd][credentialsd].
- 🟢 Discoverable credentials (resident keys)
- 🟢 Hybrid transport (caBLE v2): QR-initiated transactions
- 🟢 Hybrid transport (caBLE v2): State-assisted transactions (remember this phone)
+ - 🟢 Hybrid transport (CTAP 2.3): direct BLE L2CAP data channel, QR-initiated, no tunnel server
## Runtime requirements
@@ -50,7 +51,7 @@ Validating the relying party ID against the calling origin requires the [Public
| **Bluetooth Low Energy** | 🟢 Supported (bluez) | 🟢 Supported (bluez) |
| **NFC** | 🟢 Supported (pcsc or libnfc) | 🟢 Supported (pcsc or libnfc) |
| **TPM 2.0 (Platform)** | 🟠Planned ([#4][#4]) | 🟠Planned ([#4][#4]) |
-| **Hybrid (QR code scan, aka caBLE v2)** | N/A | 🟢 Supported |
+| **Hybrid (QR code scan, caBLE v2 + CTAP 2.3)** | N/A | 🟢 Supported |
## Example programs
@@ -72,7 +73,8 @@ WebAuthn examples consume and emit JSON per the [WebAuthn IDL][webauthn].
| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --example webauthn_hid` |
| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | — |
| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc --example webauthn_nfc` |
-| **Hybrid (caBLE v2)** | — | `cargo run --example webauthn_cable` |
+| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --example webauthn_cable` |
+| **Hybrid (caBLE v2)** | — | `cargo run --example webauthn_cable_wss` |
[^nfc]: `nfc-backend-pcsc` is pure userspace and recommended on most systems. `nfc-backend-libnfc` requires the `libnfc` system library. Both can be enabled together; the first FIDO device found by either backend is used.
diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml
index 6e90cf1..633f1ce 100644
--- a/libwebauthn/Cargo.toml
+++ b/libwebauthn/Cargo.toml
@@ -77,6 +77,7 @@ tokio-stream = "0.1"
snow = { version = "0.10", features = ["use-p256"] }
ctap-types = { version = "0.4.0" }
btleplug = "0.11.7"
+bluer = { version = "0.17", default-features = false, features = ["l2cap"] }
thiserror = "2.0.12"
serde_json = "1.0.141"
apdu-core = { version = "0.4.0", optional = true }
@@ -125,6 +126,10 @@ required-features = ["nfc"]
name = "webauthn_cable"
path = "examples/ceremony/webauthn_cable.rs"
+[[example]]
+name = "webauthn_cable_wss"
+path = "examples/ceremony/webauthn_cable_wss.rs"
+
[[example]]
name = "webauthn_extensions_hid"
path = "examples/features/webauthn_extensions_hid.rs"
diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs
index 09a4467..779406b 100644
--- a/libwebauthn/examples/ceremony/webauthn_cable.rs
+++ b/libwebauthn/examples/ceremony/webauthn_cable.rs
@@ -1,19 +1,18 @@
+//! caBLE / CTAP 2.3 hybrid: the QR advertises both the WebSocket tunnel and
+//! the BLE L2CAP channel so the authenticator can pick either one. Transient
+//! MakeCredential only.
use std::error::Error;
-use std::sync::Arc;
-use std::time::Duration;
use libwebauthn::transport::cable::is_available;
-use libwebauthn::transport::cable::known_devices::{
- CableKnownDevice, ClientPayloadHint, EphemeralDeviceInfoStore,
+use libwebauthn::transport::cable::qr_code_device::{
+ CableQrCodeDevice, CableTransports, QrCodeOperationHint,
};
-use libwebauthn::transport::cable::qr_code_device::{CableQrCodeDevice, QrCodeOperationHint};
use qrcode::render::unicode;
use qrcode::QrCode;
-use tokio::time::sleep;
use libwebauthn::ops::webauthn::{
- DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin, WebAuthnIDL as _,
+ WebAuthnIDLResponse as _,
};
use libwebauthn::transport::{Channel as _, Device};
use libwebauthn::webauthn::WebAuthn;
@@ -46,15 +45,6 @@ const MAKE_CREDENTIAL_REQUEST: &str = r#"
}
"#;
-const GET_ASSERTION_REQUEST: &str = r#"
-{
- "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
- "timeout": 120000,
- "rpId": "example.org",
- "userVerification": "discouraged"
-}
-"#;
-
#[tokio::main]
pub async fn main() -> Result<(), Box> {
common::setup_logging();
@@ -64,74 +54,39 @@ pub async fn main() -> Result<(), Box> {
return Err("Cable transport not available".into());
}
- let device_info_store = Arc::new(EphemeralDeviceInfoStore::default());
let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin");
let psl = DatFilePublicSuffixList::from_system_file().expect(
"PSL not available; install the publicsuffix-list package or pass an explicit path",
);
- {
- let mut device: CableQrCodeDevice = CableQrCodeDevice::new_persistent(
- QrCodeOperationHint::MakeCredential,
- device_info_store.clone(),
- )?;
-
- println!("Created QR code, awaiting for advertisement.");
- let qr_code = QrCode::new(device.qr_code.to_string()).unwrap();
- let image = qr_code
- .render::()
- .dark_color(unicode::Dense1x2::Light)
- .light_color(unicode::Dense1x2::Dark)
- .build();
- println!("{}", image);
+ let mut device: CableQrCodeDevice = CableQrCodeDevice::new_transient(
+ QrCodeOperationHint::MakeCredential,
+ CableTransports::CloudAssistedOrLocal,
+ )?;
- let mut channel = device.channel().await.unwrap();
- println!("Tunnel established {:?}", channel);
+ println!("Created QR code, awaiting for advertisement.");
+ let qr_code = QrCode::new(device.qr_code.to_string()).unwrap();
+ let image = qr_code
+ .render::()
+ .dark_color(unicode::Dense1x2::Light)
+ .light_color(unicode::Dense1x2::Dark)
+ .build();
+ println!("{}", image);
- let state_recv = channel.get_ux_update_receiver();
- tokio::spawn(common::handle_cable_updates(state_recv));
-
- let request =
- MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST)
- .expect("Failed to parse request JSON");
-
- let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
- let response_json = response
- .to_json_string(&request, JsonFormat::Prettified)
- .expect("Failed to serialize MakeCredential response");
- println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
- }
-
- println!("Waiting for 5 seconds before contacting the device...");
- sleep(Duration::from_secs(5)).await;
-
- let all_devices = device_info_store.list_all().await;
- let (_known_device_id, known_device_info) =
- all_devices.first().expect("No known devices found");
-
- let mut known_device: CableKnownDevice = CableKnownDevice::new(
- ClientPayloadHint::GetAssertion,
- known_device_info,
- device_info_store.clone(),
- )
- .await
- .unwrap();
-
- let mut channel = known_device.channel().await.unwrap();
- println!("Tunnel established {:?}", channel);
+ let mut channel = device.channel().await.unwrap();
+ println!("Channel established {:?}", channel);
let state_recv = channel.get_ux_update_receiver();
tokio::spawn(common::handle_cable_updates(state_recv));
- let request = GetAssertionRequest::from_json(&request_origin, &psl, GET_ASSERTION_REQUEST)
+ let request = MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST)
.expect("Failed to parse request JSON");
- let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap();
- for assertion in &response.assertions {
- let assertion_json = assertion
- .to_json_string(&request, JsonFormat::Prettified)
- .expect("Failed to serialize GetAssertion response");
- println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}");
- }
+
+ let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
+ let response_json = response
+ .to_json_string(&request, JsonFormat::Prettified)
+ .expect("Failed to serialize MakeCredential response");
+ println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
Ok(())
}
diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs
new file mode 100644
index 0000000..6496aeb
--- /dev/null
+++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs
@@ -0,0 +1,168 @@
+use std::error::Error;
+use std::sync::Arc;
+use std::time::Duration;
+
+use libwebauthn::transport::cable::is_available;
+use libwebauthn::transport::cable::known_devices::{
+ CableKnownDevice, ClientPayloadHint, EphemeralDeviceInfoStore,
+};
+use libwebauthn::transport::cable::qr_code_device::{
+ CableQrCodeDevice, CableTransports, QrCodeOperationHint,
+};
+use qrcode::render::unicode;
+use qrcode::QrCode;
+use tokio::time::sleep;
+
+use libwebauthn::ops::webauthn::{
+ DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
+ WebAuthnIDL as _, WebAuthnIDLResponse as _,
+};
+use libwebauthn::transport::cable::channel::CableChannel;
+use libwebauthn::transport::{Channel as _, Device};
+use libwebauthn::webauthn::WebAuthn;
+
+#[path = "../common/mod.rs"]
+mod common;
+
+const MAKE_CREDENTIAL_REQUEST: &str = r#"
+{
+ "rp": {
+ "id": "example.org",
+ "name": "Example Relying Party"
+ },
+ "user": {
+ "id": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg",
+ "name": "Mario Rossi",
+ "displayName": "Mario Rossi"
+ },
+ "challenge": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg",
+ "pubKeyCredParams": [
+ {"type": "public-key", "alg": -7}
+ ],
+ "timeout": 120000,
+ "excludeCredentials": [],
+ "authenticatorSelection": {
+ "residentKey": "discouraged",
+ "userVerification": "preferred"
+ },
+ "attestation": "none"
+}
+"#;
+
+const GET_ASSERTION_REQUEST: &str = r#"
+{
+ "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
+ "timeout": 120000,
+ "rpId": "example.org",
+ "userVerification": "discouraged"
+}
+"#;
+
+#[tokio::main]
+pub async fn main() -> Result<(), Box> {
+ common::setup_logging();
+
+ if !is_available().await {
+ eprintln!("No Bluetooth adapter found. Cable/Hybrid transport is unavailable.");
+ return Err("Cable transport not available".into());
+ }
+
+ let device_info_store = Arc::new(EphemeralDeviceInfoStore::default());
+ let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin");
+ let psl = DatFilePublicSuffixList::from_system_file().expect(
+ "PSL not available; install the publicsuffix-list package or pass an explicit path",
+ );
+
+ {
+ let mut device: CableQrCodeDevice = CableQrCodeDevice::new_persistent(
+ QrCodeOperationHint::MakeCredential,
+ device_info_store.clone(),
+ CableTransports::CloudAssistedOnly,
+ )?;
+
+ println!("Created QR code, awaiting for advertisement.");
+ let qr_code = QrCode::new(device.qr_code.to_string()).unwrap();
+ let image = qr_code
+ .render::()
+ .dark_color(unicode::Dense1x2::Light)
+ .light_color(unicode::Dense1x2::Dark)
+ .build();
+ println!("{}", image);
+
+ let mut channel = device.channel().await.unwrap();
+ println!("Channel established {:?}", channel);
+
+ let state_recv = channel.get_ux_update_receiver();
+ tokio::spawn(common::handle_cable_updates(state_recv));
+
+ let request =
+ MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST)
+ .expect("Failed to parse request JSON");
+
+ let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
+ let response_json = response
+ .to_json_string(&request, JsonFormat::Prettified)
+ .expect("Failed to serialize MakeCredential response");
+ println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
+ }
+
+ println!("Waiting for 5 seconds before contacting the device...");
+ sleep(Duration::from_secs(5)).await;
+
+ // Second leg: prefer state-assisted reconnection if the peer offered
+ // linking info, otherwise fall back to a fresh QR. Many authenticators
+ // don't send linking info, so the fallback is the common path.
+ let all_devices = device_info_store.list_all().await;
+ if let Some((_, known_device_info)) = all_devices.first() {
+ println!("Reconnecting state-assisted to known device...");
+ let mut known_device: CableKnownDevice = CableKnownDevice::new(
+ ClientPayloadHint::GetAssertion,
+ known_device_info,
+ device_info_store.clone(),
+ )
+ .await
+ .unwrap();
+ let mut channel = known_device.channel().await.unwrap();
+ println!("Channel established {:?}", channel);
+ run_get_assertion(&mut channel, &request_origin, &psl).await?;
+ } else {
+ println!("No known devices (peer did not offer linking). Falling back to QR.");
+ let mut device: CableQrCodeDevice = CableQrCodeDevice::new_persistent(
+ QrCodeOperationHint::GetAssertionRequest,
+ device_info_store.clone(),
+ CableTransports::CloudAssistedOnly,
+ )?;
+ let qr_code = QrCode::new(device.qr_code.to_string()).unwrap();
+ let image = qr_code
+ .render::()
+ .dark_color(unicode::Dense1x2::Light)
+ .light_color(unicode::Dense1x2::Dark)
+ .build();
+ println!("{}", image);
+ let mut channel = device.channel().await.unwrap();
+ println!("Channel established {:?}", channel);
+ run_get_assertion(&mut channel, &request_origin, &psl).await?;
+ }
+
+ Ok(())
+}
+
+async fn run_get_assertion(
+ channel: &mut CableChannel,
+ request_origin: &RequestOrigin,
+ psl: &DatFilePublicSuffixList,
+) -> Result<(), Box> {
+ let state_recv = channel.get_ux_update_receiver();
+ tokio::spawn(common::handle_cable_updates(state_recv));
+
+ let request = GetAssertionRequest::from_json(request_origin, psl, GET_ASSERTION_REQUEST)
+ .expect("Failed to parse request JSON");
+ let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap();
+ for assertion in &response.assertions {
+ let assertion_json = assertion
+ .to_json_string(&request, JsonFormat::Prettified)
+ .expect("Failed to serialize GetAssertion response");
+ println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}");
+ }
+ Ok(())
+}
diff --git a/libwebauthn/src/transport/cable/advertisement.rs b/libwebauthn/src/transport/cable/advertisement.rs
index e0ab7fc..1e21abb 100644
--- a/libwebauthn/src/transport/cable/advertisement.rs
+++ b/libwebauthn/src/transport/cable/advertisement.rs
@@ -1,9 +1,13 @@
+use std::collections::BTreeMap;
+
use ::btleplug::api::Central;
use futures::StreamExt;
+use serde_cbor_2 as serde_cbor;
use std::pin::pin;
use tracing::{debug, instrument, trace, warn};
use uuid::Uuid;
+use crate::proto::ctap2::cbor::Value;
use crate::transport::ble::btleplug::{self, FidoDevice};
use crate::transport::cable::crypto::trial_decrypt_advert;
use crate::transport::error::TransportError;
@@ -11,28 +15,56 @@ use crate::transport::error::TransportError;
const CABLE_UUID_FIDO: &str = "0000fff9-0000-1000-8000-00805f9b34fb";
const CABLE_UUID_GOOGLE: &str = "0000fde2-0000-1000-8000-00805f9b34fb";
+/// `transport_channel_identifier` for the BLE data channel.
+const TRANSPORT_CHANNEL_BLE: i128 = 1;
+
+/// Parsed CTAP 2.3 hybrid advertisement suffix: a CBOR map of
+/// `transport_channel_identifier` -> `channel_extra`.
+#[derive(Debug, Clone)]
+pub(crate) struct AdvertisementSuffix {
+ channels: BTreeMap,
+}
+
+impl AdvertisementSuffix {
+ pub fn from_cbor(bytes: &[u8]) -> Result {
+ let map: BTreeMap = serde_cbor::from_slice(bytes)?;
+ let channels = map
+ .into_iter()
+ .filter_map(|(k, v)| match k {
+ Value::Integer(id) => Some((id, v)),
+ _ => None,
+ })
+ .collect();
+ Ok(Self { channels })
+ }
+
+ /// L2CAP server PSM for the BLE channel, if advertised and in `u16` range.
+ pub fn ble_psm(&self) -> Option {
+ match self.channels.get(&TRANSPORT_CHANNEL_BLE) {
+ Some(Value::Integer(psm)) => u16::try_from(*psm).ok(),
+ _ => None,
+ }
+ }
+}
+
#[derive(Debug)]
pub(crate) struct DecryptedAdvert {
pub plaintext: [u8; 16],
pub _nonce: [u8; 10],
pub routing_id: [u8; 3],
pub encoded_tunnel_server_domain: u16,
+ pub suffix: Option,
}
impl From<[u8; 16]> for DecryptedAdvert {
fn from(plaintext: [u8; 16]) -> Self {
- let mut nonce = [0u8; 10];
- nonce.copy_from_slice(&plaintext[1..11]);
- let mut routing_id = [0u8; 3];
- routing_id.copy_from_slice(&plaintext[11..14]);
- let encoded_tunnel_server_domain = u16::from_le_bytes([plaintext[14], plaintext[15]]);
- let mut plaintext_fixed = [0u8; 16];
- plaintext_fixed.copy_from_slice(&plaintext[..16]);
+ let [_, n0, n1, n2, n3, n4, n5, n6, n7, n8, n9, r0, r1, r2, d0, d1] = plaintext;
Self {
- plaintext: plaintext_fixed,
- _nonce: nonce,
- routing_id,
- encoded_tunnel_server_domain,
+ plaintext,
+ _nonce: [n0, n1, n2, n3, n4, n5, n6, n7, n8, n9],
+ routing_id: [r0, r1, r2],
+ encoded_tunnel_server_domain: u16::from_le_bytes([d0, d1]),
+ suffix: None,
}
}
}
@@ -71,7 +103,20 @@ pub(crate) async fn await_advertisement(
};
trace!(?decrypted);
- let advert = DecryptedAdvert::from(decrypted);
+ let mut advert = DecryptedAdvert::from(decrypted);
+ if let Some(suffix_bytes) = data.get(20..).filter(|s| !s.is_empty()) {
+ match AdvertisementSuffix::from_cbor(suffix_bytes) {
+ Ok(suffix) => {
+ trace!(?suffix, "Parsed advertisement suffix");
+ advert.suffix = Some(suffix);
+ }
+ Err(e) => warn!(
+ ?device,
+ ?e,
+ "Failed to parse advertisement suffix, ignoring it"
+ ),
+ }
+ }
debug!(
?device,
?decrypted,
@@ -89,3 +134,62 @@ pub(crate) async fn await_advertisement(
warn!("BLE advertisement discovery stream terminated");
Err(TransportError::TransportUnavailable)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cbor_map(entries: &[(Value, Value)]) -> Vec {
+ let map: BTreeMap = entries.iter().cloned().collect();
+ serde_cbor::to_vec(&map).unwrap()
+ }
+
+ #[test]
+ fn suffix_yields_ble_psm() {
+ let bytes = cbor_map(&[(Value::Integer(1), Value::Integer(0x1234))]);
+ let suffix = AdvertisementSuffix::from_cbor(&bytes).unwrap();
+ assert_eq!(suffix.ble_psm(), Some(0x1234));
+ }
+
+ #[test]
+ fn suffix_ignores_unknown_channel() {
+ let bytes = cbor_map(&[(Value::Integer(0), Value::Integer(42))]);
+ let suffix = AdvertisementSuffix::from_cbor(&bytes).unwrap();
+ assert_eq!(suffix.ble_psm(), None);
+ }
+
+ #[test]
+ fn suffix_ble_psm_out_of_range_is_none() {
+ let bytes = cbor_map(&[(Value::Integer(1), Value::Integer(0x1_0000))]);
+ let suffix = AdvertisementSuffix::from_cbor(&bytes).unwrap();
+ assert_eq!(suffix.ble_psm(), None);
+ }
+
+ #[test]
+ fn suffix_ble_psm_wrong_type_is_none() {
+ let bytes = cbor_map(&[(Value::Integer(1), Value::Text("nope".into()))]);
+ let suffix = AdvertisementSuffix::from_cbor(&bytes).unwrap();
+ assert_eq!(suffix.ble_psm(), None);
+ }
+
+ #[test]
+ fn suffix_empty_map_parses_with_no_psm() {
+ let bytes = cbor_map(&[]);
+ let suffix = AdvertisementSuffix::from_cbor(&bytes).unwrap();
+ assert_eq!(suffix.ble_psm(), None);
+ }
+
+ #[test]
+ fn suffix_malformed_cbor_errors_without_panic() {
+ assert!(AdvertisementSuffix::from_cbor(&[]).is_err());
+ assert!(AdvertisementSuffix::from_cbor(&[0xFF, 0x00, 0x13]).is_err());
+ // Valid CBOR but not a map.
+ assert!(AdvertisementSuffix::from_cbor(&[0x01]).is_err());
+ }
+
+ #[test]
+ fn decrypted_advert_from_array_has_no_suffix() {
+ let advert = DecryptedAdvert::from([0u8; 16]);
+ assert!(advert.suffix.is_none());
+ }
+}
diff --git a/libwebauthn/src/transport/cable/connection_stages.rs b/libwebauthn/src/transport/cable/connection_stages.rs
index c2e844f..24ed19f 100644
--- a/libwebauthn/src/transport/cable/connection_stages.rs
+++ b/libwebauthn/src/transport/cable/connection_stages.rs
@@ -1,14 +1,17 @@
+use ::btleplug::api::{AddressType, BDAddr};
use async_trait::async_trait;
use tokio::sync::{broadcast, mpsc, watch};
-use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
-use tracing::{debug, error, instrument, trace, warn};
+use tracing::{debug, error, info, instrument, trace, warn};
use super::advertisement::{await_advertisement, DecryptedAdvert};
use super::channel::{CableUpdate, CableUxUpdate, ConnectionState};
use super::crypto::{derive, KeyPurpose};
+use super::data_channel::{CableDataChannel, WebSocketDataChannel};
use super::known_devices::{CableKnownDevice, CableKnownDeviceInfoStore, ClientNonce};
+use super::l2cap::L2capDataChannel;
+use super::protocol::{self, CableTunnelConnectionType, TunnelNoiseState};
use super::qr_code_device::CableQrCodeDevice;
-use super::tunnel::{self, CableTunnelConnectionType, TunnelNoiseState};
+use super::tunnel;
use crate::proto::ctap2::cbor::{CborRequest, CborResponse};
use crate::transport::ble::btleplug::FidoDevice;
use crate::transport::error::TransportError;
@@ -45,14 +48,24 @@ impl ProximityCheckInput {
#[derive(Debug)]
pub(crate) struct ProximityCheckOutput {
- pub _device: FidoDevice,
+ pub device: FidoDevice,
pub advert: DecryptedAdvert,
}
+/// L2CAP parameters from the advertisement suffix, for a BLE data channel.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct BleConnectionParams {
+ pub address: BDAddr,
+ pub address_type: Option,
+ pub psm: u16,
+}
+
#[derive(Debug, Clone)]
pub(crate) struct ConnectionInput {
pub tunnel_domain: String,
pub connection_type: CableTunnelConnectionType,
+ /// Some if the CMHD offered a BLE L2CAP channel; None selects WebSocket.
+ pub ble: Option,
}
impl ConnectionInput {
@@ -70,7 +83,7 @@ impl ConnectionInput {
KeyPurpose::TunnelID,
)
.map_err(|_| TransportError::InvalidKey)?;
- let tunnel_id = &tunnel_id_full[..16];
+ let tunnel_id = tunnel_id_full.get(..16).ok_or(TransportError::InvalidKey)?;
let tunnel_id_str = hex::encode(tunnel_id);
let connection_type = CableTunnelConnectionType::QrCode {
@@ -78,9 +91,22 @@ impl ConnectionInput {
tunnel_id: tunnel_id_str,
private_key: qr_device.private_key,
};
+
+ let ble = proximity_output
+ .advert
+ .suffix
+ .as_ref()
+ .and_then(|suffix| suffix.ble_psm())
+ .map(|psm| BleConnectionParams {
+ address: proximity_output.device.properties.address,
+ address_type: proximity_output.device.properties.address_type,
+ psm,
+ });
+
Ok(Self {
tunnel_domain,
connection_type,
+ ble,
})
}
@@ -106,19 +132,19 @@ impl ConnectionInput {
Self {
tunnel_domain: known_device.device_info.tunnel_domain.clone(),
connection_type,
+ ble: None,
}
}
}
-#[derive(Debug)]
pub(crate) struct ConnectionOutput {
- pub ws_stream: WebSocketStream>,
+ pub data_channel: Box,
pub connection_type: CableTunnelConnectionType,
pub tunnel_domain: String,
}
pub(crate) struct HandshakeInput {
- pub ws_stream: WebSocketStream>,
+ pub data_channel: Box,
pub psk: [u8; 32],
pub connection_type: CableTunnelConnectionType,
pub tunnel_domain: String,
@@ -133,7 +159,7 @@ impl HandshakeInput {
let advert_plaintext = &proximity_output.advert.plaintext;
let psk = derive_psk(qr_device.qr_code.qr_secret.as_ref(), advert_plaintext)?;
Ok(Self {
- ws_stream: connection_output.ws_stream,
+ data_channel: connection_output.data_channel,
psk,
connection_type: connection_output.connection_type,
tunnel_domain: connection_output.tunnel_domain,
@@ -149,7 +175,7 @@ impl HandshakeInput {
let advert_plaintext = proximity_output.advert.plaintext;
let psk = derive_psk(&link_secret, &advert_plaintext)?;
Ok(Self {
- ws_stream: connection_output.ws_stream,
+ data_channel: connection_output.data_channel,
psk,
connection_type: connection_output.connection_type,
tunnel_domain: connection_output.tunnel_domain,
@@ -158,7 +184,7 @@ impl HandshakeInput {
}
pub(crate) struct HandshakeOutput {
- pub ws_stream: WebSocketStream>,
+ pub data_channel: Box,
pub noise_state: TunnelNoiseState,
pub connection_type: CableTunnelConnectionType,
pub tunnel_domain: String,
@@ -168,7 +194,7 @@ pub(crate) struct TunnelConnectionInput {
pub connection_type: CableTunnelConnectionType,
pub tunnel_domain: String,
pub known_device_store: Option>,
- pub ws_stream: WebSocketStream>,
+ pub data_channel: Box,
pub noise_state: TunnelNoiseState,
pub cbor_tx_recv: mpsc::Receiver,
pub cbor_rx_send: mpsc::Sender,
@@ -185,7 +211,7 @@ impl TunnelConnectionInput {
connection_type: handshake_output.connection_type,
tunnel_domain: handshake_output.tunnel_domain,
known_device_store,
- ws_stream: handshake_output.ws_stream,
+ data_channel: handshake_output.data_channel,
noise_state: handshake_output.noise_state,
cbor_tx_recv,
cbor_rx_send,
@@ -252,10 +278,7 @@ pub(crate) async fn proximity_check_stage(
let (device, advert) = await_advertisement(&input.eid_key).await?;
debug!("Proximity check completed successfully");
- Ok(ProximityCheckOutput {
- _device: device,
- advert,
- })
+ Ok(ProximityCheckOutput { device, advert })
}
#[instrument(skip_all, err)]
@@ -269,16 +292,42 @@ pub(crate) async fn connection_stage(
.send_update(CableUxUpdate::CableUpdate(CableUpdate::Connecting))
.await;
- let ws_stream = tunnel::connect(&input.tunnel_domain, &input.connection_type).await?;
+ let data_channel = connect_data_channel(&input).await?;
debug!("Connection stage completed successfully");
Ok(ConnectionOutput {
- ws_stream,
+ data_channel,
connection_type: input.connection_type,
tunnel_domain: input.tunnel_domain,
})
}
+/// Connects the data transfer channel: a direct BLE L2CAP channel if the CMHD
+/// offered one, otherwise the WebSocket tunnel. A failed L2CAP attempt falls
+/// back to the tunnel, whose routing details are always present in the advert.
+async fn connect_data_channel(
+ input: &ConnectionInput,
+) -> Result, TransportError> {
+ if let Some(ble) = input.ble {
+ match L2capDataChannel::connect(ble.address, ble.address_type, ble.psm).await {
+ Ok(channel) => {
+ info!(psm = ble.psm, "Connected over BLE L2CAP");
+ return Ok(Box::new(channel));
+ }
+ Err(e) => {
+ warn!(
+ ?e,
+ "BLE L2CAP connection failed, falling back to WebSocket tunnel"
+ );
+ }
+ }
+ }
+
+ let ws_stream = tunnel::connect(&input.tunnel_domain, &input.connection_type).await?;
+ info!(tunnel_domain = %input.tunnel_domain, "Connected over WebSocket tunnel");
+ Ok(Box::new(WebSocketDataChannel::new(ws_stream)))
+}
+
#[instrument(skip_all, err)]
pub(crate) async fn handshake_stage(
input: HandshakeInput,
@@ -290,9 +339,9 @@ pub(crate) async fn handshake_stage(
.send_update(CableUxUpdate::CableUpdate(CableUpdate::Authenticating))
.await;
- let mut ws_stream = input.ws_stream;
+ let mut data_channel = input.data_channel;
let noise_state =
- tunnel::do_handshake(&mut ws_stream, input.psk, &input.connection_type).await?;
+ protocol::do_handshake(&mut *data_channel, input.psk, &input.connection_type).await?;
debug!("Handshake stage completed successfully");
ux_sender
@@ -304,7 +353,7 @@ pub(crate) async fn handshake_stage(
.await;
Ok(HandshakeOutput {
- ws_stream,
+ data_channel,
noise_state,
connection_type: input.connection_type,
tunnel_domain: input.tunnel_domain,
@@ -312,8 +361,13 @@ pub(crate) async fn handshake_stage(
}
fn derive_psk(secret: &[u8], advert_plaintext: &[u8]) -> Result<[u8; 32], Error> {
+ let derived = derive(secret, Some(advert_plaintext), KeyPurpose::Psk)?;
let mut psk: [u8; 32] = [0u8; 32];
- psk.copy_from_slice(&derive(secret, Some(advert_plaintext), KeyPurpose::Psk)?[..32]);
+ psk.copy_from_slice(
+ derived
+ .get(..32)
+ .ok_or(Error::Transport(TransportError::InvalidKey))?,
+ );
Ok(psk)
}
diff --git a/libwebauthn/src/transport/cable/crypto.rs b/libwebauthn/src/transport/cable/crypto.rs
index be9a992..bb8dc67 100644
--- a/libwebauthn/src/transport/cable/crypto.rs
+++ b/libwebauthn/src/transport/cable/crypto.rs
@@ -15,8 +15,7 @@ pub enum KeyPurpose {
}
pub fn derive(secret: &[u8], salt: Option<&[u8]>, purpose: KeyPurpose) -> Result<[u8; 64], Error> {
- let mut purpose32 = [0u8; 4];
- purpose32[0] = purpose as u8;
+ let purpose32 = [purpose as u8, 0, 0, 0];
let hkdf = Hkdf::::new(salt, secret);
let mut output = [0u8; 64];
@@ -31,12 +30,12 @@ fn reserved_bits_are_zero(plaintext: &[u8]) -> bool {
#[instrument]
pub fn trial_decrypt_advert(eid_key: &[u8], candidate_advert: &[u8]) -> Option<[u8; 16]> {
- // Both lengths are checked up front so the subsequent slicing is in bounds;
- // use `.get(..)` regardless so the clippy::indexing_slicing lint is satisfied.
- if candidate_advert.len() != 20 {
- warn!("candidate advert is not 20 bytes");
+ // Only the first 20 bytes are the encrypted advert; any remainder is the
+ // advertisement suffix and is parsed separately by the caller.
+ let Some(advert) = candidate_advert.get(..20) else {
+ warn!("candidate advert is shorter than 20 bytes");
return None;
- }
+ };
if eid_key.len() != 64 {
warn!("EID key is not 64 bytes");
@@ -44,8 +43,8 @@ pub fn trial_decrypt_advert(eid_key: &[u8], candidate_advert: &[u8]) -> Option<[
}
let mac_key = eid_key.get(32..)?;
- let advert_body = candidate_advert.get(..16)?;
- let advert_tag = candidate_advert.get(16..)?;
+ let advert_body = advert.get(..16)?;
+ let advert_tag = advert.get(16..)?;
let expected_tag = hmac_sha256(mac_key, advert_body).ok()?;
let expected_tag_truncated = expected_tag.get(..4)?;
if expected_tag_truncated != advert_tag {
@@ -72,7 +71,75 @@ pub fn trial_decrypt_advert(eid_key: &[u8], candidate_advert: &[u8]) -> Option<[
#[cfg(test)]
mod tests {
use super::derive;
+ use super::trial_decrypt_advert;
use super::KeyPurpose;
+ use crate::pin::hmac_sha256;
+ use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
+ use aes::{Aes256, Block};
+
+ /// Builds a self-consistent (eid_key, 20-byte advert) pair for a given plaintext.
+ fn make_advert(plaintext: [u8; 16]) -> ([u8; 64], [u8; 20]) {
+ let mut eid_key = [0u8; 64];
+ for (i, b) in eid_key.iter_mut().enumerate() {
+ *b = i as u8;
+ }
+ let (aes_key, mac_key) = eid_key.split_at(32);
+
+ let cipher = Aes256::new(GenericArray::from_slice(aes_key));
+ let mut block = Block::clone_from_slice(&plaintext);
+ cipher.encrypt_block(&mut block);
+
+ let tag = hmac_sha256(mac_key, &block).unwrap();
+ let mut advert = [0u8; 20];
+ let (body, tail) = advert.split_at_mut(16);
+ body.copy_from_slice(&block);
+ tail.copy_from_slice(tag.get(..4).unwrap());
+ (eid_key, advert)
+ }
+
+ #[test]
+ fn trial_decrypt_advert_accepts_exactly_20_bytes() {
+ let plaintext = [0u8; 16];
+ let (eid_key, advert) = make_advert(plaintext);
+ assert_eq!(trial_decrypt_advert(&eid_key, &advert), Some(plaintext));
+ }
+
+ #[test]
+ fn trial_decrypt_advert_ignores_suffix() {
+ let plaintext = [0u8; 16];
+ let (eid_key, advert) = make_advert(plaintext);
+
+ let mut with_suffix = advert.to_vec();
+ with_suffix.extend_from_slice(&[0xA1, 0x01, 0x19, 0x12, 0x34]);
+
+ assert_eq!(
+ trial_decrypt_advert(&eid_key, &with_suffix),
+ trial_decrypt_advert(&eid_key, &advert),
+ );
+ assert_eq!(
+ trial_decrypt_advert(&eid_key, &with_suffix),
+ Some(plaintext)
+ );
+ }
+
+ #[test]
+ fn trial_decrypt_advert_rejects_short_input() {
+ let (eid_key, advert) = make_advert([0u8; 16]);
+ assert_eq!(
+ trial_decrypt_advert(&eid_key, advert.get(..19).unwrap()),
+ None
+ );
+ assert_eq!(trial_decrypt_advert(&eid_key, &[]), None);
+ }
+
+ #[test]
+ fn trial_decrypt_advert_rejects_bad_tag() {
+ let (eid_key, mut advert) = make_advert([0u8; 16]);
+ if let Some(b) = advert.last_mut() {
+ *b ^= 0xFF;
+ }
+ assert_eq!(trial_decrypt_advert(&eid_key, &advert), None);
+ }
#[test]
fn derive_eidkey_nosalt() {
diff --git a/libwebauthn/src/transport/cable/data_channel.rs b/libwebauthn/src/transport/cable/data_channel.rs
new file mode 100644
index 0000000..9f28c1d
--- /dev/null
+++ b/libwebauthn/src/transport/cable/data_channel.rs
@@ -0,0 +1,65 @@
+//! Transport-agnostic message channel for the hybrid transport.
+use async_trait::async_trait;
+use futures::{SinkExt, StreamExt};
+use tokio::net::TcpStream;
+use tokio_tungstenite::tungstenite::Message;
+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
+use tracing::error;
+
+use crate::transport::error::TransportError;
+
+/// A bidirectional channel carrying discrete protocol messages: the Noise
+/// handshake messages, then the encrypted CTAP frames. caBLE rides this over a
+/// WebSocket tunnel; CTAP 2.3 hybrid can also ride it over a BLE L2CAP connection.
+#[async_trait]
+pub(crate) trait CableDataChannel: Send {
+ /// Sends one message as a discrete unit.
+ async fn send(&mut self, message: &[u8]) -> Result<(), TransportError>;
+
+ /// Receives the next message. `Ok(None)` signals a clean close by the peer.
+ /// Must be cancel-safe so it can be used as a `tokio::select!` branch.
+ async fn recv(&mut self) -> Result