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>, TransportError>; +} + +/// [`CableDataChannel`] over the caBLE WebSocket tunnel. Each protocol message is +/// a single binary WebSocket frame. +pub(crate) struct WebSocketDataChannel { + stream: WebSocketStream>, +} + +impl WebSocketDataChannel { + pub(crate) fn new(stream: WebSocketStream>) -> Self { + Self { stream } + } +} + +#[async_trait] +impl CableDataChannel for WebSocketDataChannel { + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { + self.stream + .send(Message::Binary(message.to_vec().into())) + .await + .map_err(|e| { + error!(?e, "Failed to send WebSocket message"); + TransportError::ConnectionFailed + }) + } + + async fn recv(&mut self) -> Result>, TransportError> { + loop { + match self.stream.next().await { + Some(Ok(Message::Binary(data))) => return Ok(Some(data.into())), + Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue, + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Ok(other)) => { + error!(?other, "Unexpected WebSocket message type"); + return Err(TransportError::ConnectionFailed); + } + Some(Err(e)) => { + error!(?e, "Failed to read WebSocket message"); + return Err(TransportError::ConnectionFailed); + } + } + } + } +} diff --git a/libwebauthn/src/transport/cable/known_devices.rs b/libwebauthn/src/transport/cable/known_devices.rs index 5782d81..fdcfef3 100644 --- a/libwebauthn/src/transport/cable/known_devices.rs +++ b/libwebauthn/src/transport/cable/known_devices.rs @@ -23,7 +23,7 @@ use tokio::task; use tracing::{debug, instrument, trace}; use super::channel::CableChannel; -use super::tunnel::{self, CableLinkingInfo}; +use super::protocol::{self, CableLinkingInfo}; use super::Cable; #[async_trait] @@ -223,7 +223,7 @@ impl<'d> Device<'d, Cable, CableChannel> for CableKnownDevice { cbor_rx_send, ); - tunnel::connection(tunnel_input).await; + protocol::connection(tunnel_input).await; ux_sender .set_connection_state(ConnectionState::Terminated) .await; diff --git a/libwebauthn/src/transport/cable/l2cap.rs b/libwebauthn/src/transport/cable/l2cap.rs new file mode 100644 index 0000000..ad19792 --- /dev/null +++ b/libwebauthn/src/transport/cable/l2cap.rs @@ -0,0 +1,248 @@ +//! [`CableDataChannel`] over a direct BLE L2CAP connection-oriented channel. +use std::str::FromStr; +use std::time::Duration; + +use async_trait::async_trait; +use btleplug::api::{AddressType, BDAddr}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::Instant; +use tracing::{debug, error, warn}; + +use super::data_channel::CableDataChannel; +use crate::transport::error::TransportError; + +/// End-of-Message sequence terminating every L2CAP message (CRLF). +const EOM: [u8; 2] = [0x0D, 0x0A]; + +/// How long to wait for the negotiated send MTU to become available after +/// `connect()` returns. If unset, bluer silently caps each write to 16 bytes, +/// fragmenting the handshake across multiple SDUs. +const MTU_READY_TIMEOUT: Duration = Duration::from_secs(2); +const MTU_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// [`CableDataChannel`] over the insecure L2CAP CoC socket the CMHD opens for CTAP 2.3 hybrid. +/// Messages are CRLF-terminated per the CTAP 2.3 hybrid draft. +pub(crate) struct L2capDataChannel { + stream: bluer::l2cap::Stream, + // Carries bytes read past a message boundary so `recv` stays cancel-safe. + read_buf: Vec, +} + +impl L2capDataChannel { + /// Connects to the peer's auto-generated PSM over an insecure L2CAP CoC. + /// + /// The socket is left at the kernel's default `sec_level` of + /// `BT_SECURITY_LOW`, which on an LE link does not trigger pairing or + /// encryption. We deliberately don't call `set_security`: + /// `BT_SECURITY_SDP` (0) is rejected by `l2cap_sock_setsockopt` with + /// `-EINVAL` on any L2CAP CoC. + pub(crate) async fn connect( + addr: BDAddr, + addr_type: Option, + psm: u16, + ) -> Result { + let (addr, addr_type) = bdaddr_to_bluer(addr, addr_type)?; + + let stream = + bluer::l2cap::Stream::connect(bluer::l2cap::SocketAddr::new(addr, addr_type, psm)) + .await + .map_err(|e| { + error!(?e, %addr, psm, "Failed to connect L2CAP CoC"); + TransportError::ConnectionFailed + })?; + + await_send_mtu(&stream).await; + + Ok(Self { + stream, + read_buf: Vec::new(), + }) + } +} + +/// Polls `BT_SNDMTU` until the kernel has the peer's MTU from the LE Credit +/// Based Connection Response. Without this, bluer's first writes assume an MTU +/// of 16 bytes and split the Noise handshake into 5+ SDUs, which some peers +/// don't reassemble before timing out. +async fn await_send_mtu(stream: &bluer::l2cap::Stream) { + let deadline = Instant::now() + MTU_READY_TIMEOUT; + loop { + if let Ok(mtu) = stream.as_ref().send_mtu() { + if mtu > 0 { + debug!(mtu, "L2CAP send MTU is available"); + return; + } + } + if Instant::now() >= deadline { + warn!( + timeout = ?MTU_READY_TIMEOUT, + "L2CAP send MTU did not become available; first writes may be capped at 16 bytes" + ); + return; + } + tokio::time::sleep(MTU_POLL_INTERVAL).await; + } +} + +#[async_trait] +impl CableDataChannel for L2capDataChannel { + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { + self.stream.write_all(message).await.map_err(|e| { + error!(?e, "Failed to write L2CAP message"); + TransportError::IoError(e.kind()) + })?; + self.stream.write_all(&EOM).await.map_err(|e| { + error!(?e, "Failed to write L2CAP EOM"); + TransportError::IoError(e.kind()) + })?; + Ok(()) + } + + async fn recv(&mut self) -> Result>, TransportError> { + loop { + if let Some(message) = split_next_message(&mut self.read_buf) { + return Ok(Some(message)); + } + let mut chunk = [0u8; 1024]; + let n = match self.stream.read(&mut chunk).await { + Ok(n) => n, + // The peer tearing down after a successful ceremony surfaces as + // ECONNRESET / EPIPE rather than a clean EOF; treat it like EOF + // when nothing is half-buffered. + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::UnexpectedEof + ) => + { + 0 + } + Err(e) => { + error!(?e, "Failed to read L2CAP message"); + return Err(TransportError::IoError(e.kind())); + } + }; + if n == 0 { + // Peer closed; only a clean close if nothing is half-buffered. + if self.read_buf.is_empty() { + return Ok(None); + } + error!(buffered = self.read_buf.len(), "L2CAP closed mid-message"); + return Err(TransportError::ConnectionLost); + } + self.read_buf + .extend_from_slice(chunk.get(..n).unwrap_or(&[])); + } + } +} + +/// Drains the first CRLF-terminated message from `buf`, returning it without the +/// CRLF. Returns `None` (leaving `buf` untouched) until a full message is buffered. +fn split_next_message(buf: &mut Vec) -> Option> { + let eom = buf.windows(EOM.len()).position(|w| w == EOM)?; + let message: Vec = buf.get(..eom).unwrap_or(&[]).to_vec(); + buf.drain(..eom + EOM.len()); + Some(message) +} + +/// Converts a btleplug address to a bluer one. Uses a `Display`/`FromStr` round +/// trip (both render `AA:BB:CC:DD:EE:FF`) to sidestep byte-order pitfalls. +fn bdaddr_to_bluer( + addr: BDAddr, + addr_type: Option, +) -> Result<(bluer::Address, bluer::AddressType), TransportError> { + let addr = bluer::Address::from_str(&addr.to_string()).map_err(|e| { + error!(?e, "Failed to parse Bluetooth address"); + TransportError::InvalidEndpoint + })?; + let addr_type = match addr_type { + Some(AddressType::Public) => bluer::AddressType::LePublic, + Some(AddressType::Random) => bluer::AddressType::LeRandom, + None => { + warn!("Peer address type unknown; defaulting to LE public"); + bluer::AddressType::LePublic + } + }; + Ok((addr, addr_type)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_message_across_reads() { + let mut buf = b"hel".to_vec(); + assert_eq!(split_next_message(&mut buf), None); + assert_eq!(buf, b"hel"); + buf.extend_from_slice(b"lo\r\n"); + assert_eq!(split_next_message(&mut buf), Some(b"hello".to_vec())); + assert!(buf.is_empty()); + } + + #[test] + fn split_multiple_buffered_messages() { + let mut buf = b"one\r\ntwo\r\nthr".to_vec(); + assert_eq!(split_next_message(&mut buf), Some(b"one".to_vec())); + assert_eq!(split_next_message(&mut buf), Some(b"two".to_vec())); + assert_eq!(split_next_message(&mut buf), None); + assert_eq!(buf, b"thr"); + } + + #[test] + fn split_nothing_buffered() { + let mut buf = Vec::new(); + assert_eq!(split_next_message(&mut buf), None); + assert!(buf.is_empty()); + } + + #[test] + fn split_empty_message() { + let mut buf = b"\r\nrest".to_vec(); + assert_eq!(split_next_message(&mut buf), Some(Vec::new())); + assert_eq!(buf, b"rest"); + } + + #[test] + fn split_handles_binary_payload() { + let mut buf = vec![0x00, 0xFF, 0x0D, 0x0A]; + assert_eq!(split_next_message(&mut buf), Some(vec![0x00, 0xFF])); + assert!(buf.is_empty()); + } + + #[test] + fn eom_is_crlf() { + assert_eq!(EOM, [0x0D, 0x0A]); + } + + #[test] + fn address_round_trip_public() { + let bd = BDAddr::from([0x1F, 0x2A, 0x00, 0xCC, 0x22, 0xF1]); + let (addr, addr_type) = bdaddr_to_bluer(bd, Some(AddressType::Public)).unwrap(); + assert_eq!( + addr, + bluer::Address::new([0x1F, 0x2A, 0x00, 0xCC, 0x22, 0xF1]) + ); + assert_eq!(addr_type, bluer::AddressType::LePublic); + } + + #[test] + fn address_round_trip_random() { + let bd = BDAddr::from([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + let (addr, addr_type) = bdaddr_to_bluer(bd, Some(AddressType::Random)).unwrap(); + assert_eq!( + addr, + bluer::Address::new([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + ); + assert_eq!(addr_type, bluer::AddressType::LeRandom); + } + + #[test] + fn address_type_defaults_to_public() { + let bd = BDAddr::from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); + let (_, addr_type) = bdaddr_to_bluer(bd, None).unwrap(); + assert_eq!(addr_type, bluer::AddressType::LePublic); + } +} diff --git a/libwebauthn/src/transport/cable/mod.rs b/libwebauthn/src/transport/cable/mod.rs index 118e5a8..d579fac 100644 --- a/libwebauthn/src/transport/cable/mod.rs +++ b/libwebauthn/src/transport/cable/mod.rs @@ -1,7 +1,10 @@ use std::fmt::Display; mod crypto; +mod data_channel; mod digit_encode; +mod l2cap; +mod protocol; pub mod advertisement; pub mod channel; diff --git a/libwebauthn/src/transport/cable/protocol.rs b/libwebauthn/src/transport/cable/protocol.rs new file mode 100644 index 0000000..b15dccd --- /dev/null +++ b/libwebauthn/src/transport/cable/protocol.rs @@ -0,0 +1,695 @@ +//! Transport-agnostic Noise handshake and encrypted CTAP framing for the +//! hybrid transport. Runs over any [`CableDataChannel`]. +use std::collections::BTreeMap; +use std::sync::Arc; + +use hmac::{Hmac, Mac}; +use p256::{ecdh, NonZeroScalar}; +use p256::{PublicKey, SecretKey}; +use serde::Deserialize; +use serde_bytes::ByteBuf; +use serde_cbor_2 as serde_cbor; +use serde_indexed::DeserializeIndexed; +use sha2::Sha256; +use snow::{Builder, TransportState}; +use tokio::sync::mpsc::Sender; +use tracing::{debug, error, trace, warn}; + +use super::data_channel::CableDataChannel; +use super::known_devices::ClientPayload; +use super::known_devices::{CableKnownDeviceInfo, CableKnownDeviceInfoStore}; +use crate::proto::ctap2::cbor::{self, CborRequest, CborResponse, Value}; +use crate::proto::ctap2::{Ctap2CommandCode, Ctap2GetInfoResponse}; +use crate::transport::cable::connection_stages::TunnelConnectionInput; +use crate::transport::cable::known_devices::CableKnownDeviceId; +use crate::transport::error::TransportError; +use crate::webauthn::error::Error; + +const P256_X962_LENGTH: usize = 65; +const MAX_CBOR_SIZE: usize = 1024 * 1024; +const PADDING_GRANULARITY: usize = 32; + +const CABLE_PROLOGUE_STATE_ASSISTED: &[u8] = &[0u8]; +const CABLE_PROLOGUE_QR_INITIATED: &[u8] = &[1u8]; + +#[derive(Debug, Clone)] +struct CableTunnelMessage { + message_type: CableTunnelMessageType, + payload: ByteBuf, +} + +impl CableTunnelMessage { + pub fn new(message_type: CableTunnelMessageType, payload: &[u8]) -> Self { + Self { + message_type, + payload: ByteBuf::from(payload.to_vec()), + } + } + pub fn from_slice(slice: &[u8]) -> Result { + let (type_byte, payload) = slice + .split_first() + .ok_or(Error::Transport(TransportError::InvalidFraming))?; + if payload.is_empty() { + return Err(Error::Transport(TransportError::InvalidFraming)); + } + + let message_type = match *type_byte { + 0 => CableTunnelMessageType::Shutdown, + 1 => CableTunnelMessageType::Ctap, + 2 => CableTunnelMessageType::Update, + _ => { + return Err(Error::Transport(TransportError::InvalidFraming)); + } + }; + + Ok(Self { + message_type, + payload: ByteBuf::from(payload.to_vec()), + }) + } + + pub fn to_vec(&self) -> Vec { + let mut vec = Vec::new(); + // TODO: multiple versions + vec.push(self.message_type as u8); + vec.extend(self.payload.iter()); + vec + } +} + +#[derive(Clone, Debug, DeserializeIndexed)] +struct CableInitialMessage { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x00)] + pub _padding: Option, + + #[serde(index = 0x01)] + pub info: ByteBuf, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x03)] + pub _supported_features: Option>, +} + +#[derive(Clone, Debug)] +pub(crate) struct CableLinkingInfo { + /// Used by the tunnel to identify the authenticator (eg. Android FCM token) + pub contact_id: Vec, + /// Used by the authenticator to identify the client platform + pub link_id: Vec, + /// Shared secret between authenticator and client platform + pub link_secret: Vec, + /// Authenticator's public key, X9.62 uncompressed format + pub authenticator_public_key: Vec, + /// User-friendly name of the authenticator + pub authenticator_name: String, + /// HMAC of the handshake hash (Noise's channel binding value) using the + /// shared secret (link_secret) as key + #[allow(dead_code)] + pub handshake_signature: Vec, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, Deserialize)] +enum CableTunnelMessageType { + Shutdown = 0, + Ctap = 1, + Update = 2, +} + +#[derive(Clone)] +pub(crate) enum CableTunnelConnectionType { + QrCode { + routing_id: String, + tunnel_id: String, + private_key: NonZeroScalar, + }, + KnownDevice { + contact_id: String, + authenticator_public_key: Vec, + client_payload: ClientPayload, + }, +} + +impl std::fmt::Debug for CableTunnelConnectionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::QrCode { + routing_id, + tunnel_id, + private_key: _, + } => f + .debug_struct("QrCode") + .field("routing_id", routing_id) + .field("tunnel_id", tunnel_id) + .field("private_key", &"[REDACTED]") + .finish(), + Self::KnownDevice { + contact_id, + authenticator_public_key, + client_payload, + } => f + .debug_struct("KnownDevice") + .field("contact_id", contact_id) + .field("authenticator_public_key", authenticator_public_key) + .field("client_payload", client_payload) + .finish(), + } + } +} + +pub(crate) struct TunnelNoiseState { + pub transport_state: TransportState, + #[allow(dead_code)] + pub handshake_hash: Vec, +} + +pub(crate) async fn do_handshake( + data_channel: &mut dyn CableDataChannel, + psk: [u8; 32], + connection_type: &CableTunnelConnectionType, +) -> Result { + let noise_handshake = match connection_type { + CableTunnelConnectionType::QrCode { private_key, .. } => { + let local_private_key = private_key.to_owned().to_bytes(); + Builder::new("Noise_KNpsk0_P256_AESGCM_SHA256".parse()?) + .prologue(CABLE_PROLOGUE_QR_INITIATED)? + .local_private_key(local_private_key.as_slice())? + .psk(0, &psk)? + .build_initiator() + } + CableTunnelConnectionType::KnownDevice { + authenticator_public_key, + .. + } => Builder::new("Noise_NKpsk0_P256_AESGCM_SHA256".parse()?) + .prologue(CABLE_PROLOGUE_STATE_ASSISTED)? + .remote_public_key(authenticator_public_key)? + .psk(0, &psk)? + .build_initiator(), + }; + + // Build the Noise handshake as the initiator + let mut noise_handshake = match noise_handshake { + Ok(handshake) => handshake, + Err(e) => { + error!(?e, "Failed to build Noise handshake"); + return Err(TransportError::ConnectionFailed); + } + }; + + let mut initial_msg_buffer = vec![0u8; 1024]; + let initial_msg_len = match noise_handshake.write_message(&[], &mut initial_msg_buffer) { + Ok(msg_len) => msg_len, + Err(e) => { + error!(?e, "Failed to write initial handshake message"); + return Err(TransportError::ConnectionFailed); + } + }; + + let initial_msg: Vec = initial_msg_buffer + .get(..initial_msg_len) + .map(<[u8]>::to_vec) + .ok_or(TransportError::ConnectionFailed)?; + trace!( + { handshake = ?initial_msg }, + "Sending initial handshake message" + ); + + data_channel.send(&initial_msg).await?; + debug!("Sent initial handshake message"); + + // Read the response from the peer and process it + let response = match data_channel.recv().await { + Ok(Some(response)) => { + debug!(response_len = response.len(), "Received handshake response"); + trace!(?response); + response + } + Ok(None) => { + error!("Connection was closed before handshake was complete"); + return Err(TransportError::ConnectionFailed); + } + Err(e) => { + error!(?e, "Failed to read handshake response"); + return Err(e); + } + }; + + if response.len() < P256_X962_LENGTH { + error!( + { len = response.len() }, + "Peer handshake message is too short" + ); + return Err(TransportError::ConnectionFailed); + } + + let mut payload = [0u8; 1024]; + let payload_len = match noise_handshake.read_message(&response, &mut payload) { + Ok(len) => len, + Err(e) => { + error!(?e, "Failed to read handshake response message"); + return Err(TransportError::ConnectionFailed); + } + }; + + debug!( + { handshake = ?payload.get(..payload_len) }, + "Received handshake response" + ); + + if !noise_handshake.is_handshake_finished() { + error!("Handshake did not complete"); + return Err(TransportError::ConnectionFailed); + } + + Ok(TunnelNoiseState { + handshake_hash: noise_handshake.get_handshake_hash().to_vec(), + transport_state: noise_handshake.into_transport_mode()?, + }) +} + +pub(crate) async fn connection(mut input: TunnelConnectionInput) { + // Fetch the initial message + let get_info_response_serialized: Vec = match input.data_channel.recv().await { + Ok(Some(message)) => match connection_recv_initial(message, &mut input.noise_state).await { + Ok(initial) => initial, + Err(e) => { + error!(?e, "Failed to process initial message"); + return; + } + }, + Ok(None) => { + error!("Connection closed before initial message was received"); + return; + } + Err(e) => { + error!(?e, "Failed to read initial message"); + return; + } + }; + debug!(?get_info_response_serialized, "Received initial message"); + + loop { + // Wait for a message on the data channel, or a request to send on cbor_tx_recv + tokio::select! { + result = input.data_channel.recv() => { + match result { + Ok(Some(message)) => { + debug!("Received data channel message"); + trace!(?message); + let _ = connection_recv( + &input.connection_type, + &input.tunnel_domain, + &input.known_device_store, + message, + &input.cbor_rx_send, + &mut input.noise_state, + ) + .await; + } + Ok(None) => { + debug!("Data channel closed, closing connection"); + return; + } + Err(e) => { + error!(?e, "Failed to read encrypted CBOR message"); + return; + } + } + } + Some(request) = input.cbor_tx_recv.recv() => { + match request.command { + // Optimisation: respond to GetInfo requests immediately with the cached response + Ctap2CommandCode::AuthenticatorGetInfo => { + debug!("Responding to GetInfo request with cached response"); + let response = CborResponse::new_success_from_slice(&get_info_response_serialized); + let _ = input.cbor_rx_send.send(response).await; + } + _ => { + debug!(?request.command, "Sending CBOR request"); + let _ = connection_send(request, &mut *input.data_channel, &mut input.noise_state).await; + } + } + } + }; + } +} + +async fn connection_send( + request: CborRequest, + data_channel: &mut dyn CableDataChannel, + noise_state: &mut TunnelNoiseState, +) -> Result<(), Error> { + debug!("Sending CBOR request"); + trace!(?request); + + let cbor_request = request + .raw_long() + .map_err(|e| TransportError::IoError(e.kind()))?; + if cbor_request.len() > MAX_CBOR_SIZE { + error!( + cbor_request_len = cbor_request.len(), + "CBOR request too large" + ); + return Err(Error::Transport(TransportError::InvalidFraming)); + } + trace!(?cbor_request, cbor_request_len = cbor_request.len()); + + let extra_bytes = PADDING_GRANULARITY - (cbor_request.len() % PADDING_GRANULARITY); + let padded_len = cbor_request.len() + extra_bytes; + + let mut padded_cbor_request = cbor_request.clone(); + padded_cbor_request.resize(padded_len, 0u8); + if let Some(last) = padded_cbor_request.last_mut() { + *last = (extra_bytes - 1) as u8; + } + + let frame = CableTunnelMessage::new(CableTunnelMessageType::Ctap, &padded_cbor_request); + let frame_serialized = frame.to_vec(); + trace!(?frame_serialized); + + let mut encrypted_frame = vec![0u8; MAX_CBOR_SIZE + 1]; + match noise_state + .transport_state + .write_message(&frame_serialized, &mut encrypted_frame) + { + Ok(size) => { + encrypted_frame.resize(size, 0u8); + } + Err(e) => { + error!(?e, "Failed to encrypt frame"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + } + + debug!("Sending encrypted frame"); + trace!(?encrypted_frame); + + data_channel.send(&encrypted_frame).await?; + Ok(()) +} + +/// Strip the trailing padding-length byte and `padding_len` bytes of padding +/// from a decrypted Noise transport frame, returning `InvalidFraming` on an +/// empty plaintext or a declared padding length that exceeds the frame. +fn strip_frame_padding(mut decrypted_frame: Vec) -> Result, Error> { + let padding_len = match decrypted_frame.last() { + Some(&b) => b as usize, + None => { + error!("Decrypted frame is empty; cannot read padding length"); + return Err(Error::Transport(TransportError::InvalidFraming)); + } + }; + let new_len = decrypted_frame + .len() + .checked_sub(padding_len + 1) + .ok_or_else(|| { + error!( + frame_len = decrypted_frame.len(), + padding_len, "Padding length exceeds frame length" + ); + Error::Transport(TransportError::InvalidFraming) + })?; + decrypted_frame.truncate(new_len); + Ok(decrypted_frame) +} + +async fn decrypt_frame( + encrypted_frame: Vec, + noise_state: &mut TunnelNoiseState, +) -> Result, Error> { + let mut decrypted_frame = vec![0u8; MAX_CBOR_SIZE]; + match noise_state + .transport_state + .read_message(&encrypted_frame, &mut decrypted_frame) + { + Ok(size) => { + debug!(decrypted_frame_len = size, "Decrypted CBOR response"); + decrypted_frame.resize(size, 0u8); + trace!(?decrypted_frame); + } + Err(e) => { + error!(?e, "Failed to decrypt CBOR response"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + } + + let decrypted_frame = strip_frame_padding(decrypted_frame)?; + trace!( + ?decrypted_frame, + decrypted_frame_len = decrypted_frame.len(), + "Trimmed padding" + ); + + Ok(decrypted_frame) +} + +async fn connection_recv_initial( + encrypted_frame: Vec, + noise_state: &mut TunnelNoiseState, +) -> Result, Error> { + let decrypted_frame = decrypt_frame(encrypted_frame, noise_state).await?; + + let initial_message: CableInitialMessage = match cbor::from_slice(&decrypted_frame) { + Ok(initial_message) => initial_message, + Err(e) => { + error!(?e, "Failed to decode initial message"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + }; + + let _: Ctap2GetInfoResponse = match cbor::from_slice(&initial_message.info) { + Ok(get_info_response) => get_info_response, + Err(e) => { + error!(?e, "Failed to decode GetInfo response"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + }; + + Ok(initial_message.info.to_vec()) +} + +async fn connection_recv_update(message: &[u8]) -> Result, Error> { + // TODO(#66): Android adds a 999-key to the end the message, which is not part of the standard. + // For now, we parse the message to a map and manuually import fields. + + let update_message: BTreeMap = match serde_cbor::from_slice(message) { + Ok(update_message) => update_message, + Err(e) => { + error!(?e, "Failed to decode update message"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + }; + + let Some(Value::Map(linking_info_map)) = update_message.get(&Value::Integer(0x01)) else { + warn!("Empty linking info map"); + return Ok(None); + }; + + trace!(?linking_info_map); + + let Some(Value::Bytes(contact_id)) = linking_info_map.get(&Value::Integer(0x01)) else { + warn!("Missing contact ID"); + return Ok(None); + }; + + let Some(Value::Bytes(link_id)) = linking_info_map.get(&Value::Integer(0x02)) else { + warn!("Missing link ID"); + return Ok(None); + }; + + let Some(Value::Bytes(link_secret)) = linking_info_map.get(&Value::Integer(0x03)) else { + warn!("Missing link secret"); + return Ok(None); + }; + + let Some(Value::Bytes(authenticator_public_key)) = linking_info_map.get(&Value::Integer(0x04)) + else { + warn!("Missing authenticator public key"); + return Ok(None); + }; + + let Some(Value::Text(authenticator_name)) = linking_info_map.get(&Value::Integer(0x05)) else { + warn!("Missing authenticator name"); + return Ok(None); + }; + + let Some(Value::Bytes(handshake_signature)) = linking_info_map.get(&Value::Integer(0x06)) + else { + warn!("Missing handshake_signature"); + return Ok(None); + }; + + let linking_info = CableLinkingInfo { + contact_id: contact_id.clone(), + link_id: link_id.clone(), + link_secret: link_secret.clone(), + authenticator_public_key: authenticator_public_key.clone(), + authenticator_name: authenticator_name.clone(), + handshake_signature: handshake_signature.clone(), + }; + + Ok(Some(linking_info)) +} + +async fn connection_recv( + connection_type: &CableTunnelConnectionType, + tunnel_domain: &str, + known_device_store: &Option>, + encrypted_frame: Vec, + cbor_rx_send: &Sender, + noise_state: &mut TunnelNoiseState, +) -> Result<(), Error> { + let decrypted_frame = decrypt_frame(encrypted_frame, noise_state).await?; + + // TODO handle the decrypted frame + let cable_message: CableTunnelMessage = match CableTunnelMessage::from_slice(&decrypted_frame) { + Ok(cable_message) => cable_message, + Err(e) => { + error!(?e, "Failed to decode CABLE tunnel message"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + }; + + trace!(?cable_message); + match cable_message.message_type { + CableTunnelMessageType::Shutdown => { + // Unexpected shutdown message + error!("Received unexpected shutdown message"); + return Err(Error::Transport(TransportError::ConnectionFailed)); + } + CableTunnelMessageType::Ctap => { + // Handle the CTAP message + let cbor_response: CborResponse = (&cable_message.payload.to_vec()) + .try_into() + .or(Err(TransportError::InvalidFraming))?; + + debug!("Received CBOR response"); + trace!(?cbor_response); + cbor_rx_send + .send(cbor_response) + .await + .or(Err(TransportError::ConnectionFailed))?; + } + CableTunnelMessageType::Update => { + // Handle the update message + let maybe_update_message: Option = + connection_recv_update(&cable_message.payload).await?; + + let Some(linking_info) = maybe_update_message else { + warn!("Ignoring update message without linking info"); + return Ok(()); + }; + + let CableTunnelConnectionType::QrCode { private_key, .. } = connection_type else { + warn!("Ignoring update message for non-QR code connection"); + return Ok(()); + }; + + debug!("Received update message with linking info"); + trace!(?linking_info); + + let device_id: CableKnownDeviceId = (&linking_info).into(); + match known_device_store { + Some(store) => { + match parse_known_device(private_key, tunnel_domain, &linking_info, noise_state) + { + Ok(known_device) => { + debug!(?device_id, "Updating known device"); + trace!(?known_device); + store.put_known_device(&device_id, &known_device).await; + } + Err(e) => { + error!( + ?e, + "Invalid update message from authenticator, forgetting device" + ); + store.delete_known_device(&device_id).await; + return Err(Error::Transport(TransportError::TransportUnavailable)); + } + } + } + None => { + warn!("Ignoring update message without a device store"); + } + }; + } + }; + + Ok(()) +} + +/// Validation requires a shared key computed on the QR code ephemeral identity key (private_key here). +/// We're currently unable to validate the signature on linking information received for state-assisted transactions, +/// so these should be discarded. This is the same Chrome currently does, although it may change in future spec versions. +/// See: https://github.com/chromium/chromium/blob/88e250200e59daf52554bcc74870138143a830c4/device/fido/cable/fido_tunnel_device.cc#L547-L549 +fn parse_known_device( + private_key: &NonZeroScalar, + tunnel_domain: &str, + linking_info: &CableLinkingInfo, + noise_state: &TunnelNoiseState, +) -> Result { + let known_device = CableKnownDeviceInfo::new(tunnel_domain, linking_info)?; + let secret_key = SecretKey::from(private_key); + + let Ok(authenticator_public_key) = + PublicKey::from_sec1_bytes(&linking_info.authenticator_public_key) + else { + error!("Failed to parse public key."); + return Err(Error::Transport(TransportError::InvalidKey)); + }; + + let shared_secret: Vec = ecdh::diffie_hellman( + secret_key.to_nonzero_scalar(), + authenticator_public_key.as_affine(), + ) + .raw_secret_bytes() + .to_vec(); + + let mut hmac = Hmac::::new_from_slice(&shared_secret) + .map_err(|_| Error::Transport(TransportError::InvalidKey))?; + hmac.update(&noise_state.handshake_hash); + let expected_mac = hmac.finalize().into_bytes().to_vec(); + + if expected_mac != linking_info.handshake_signature { + error!("Invalid handshake signature, rejecting update message"); + trace!(?expected_mac, ?linking_info.handshake_signature); + return Err(Error::Transport(TransportError::InvalidSignature)); + } + + debug!("Parsed known device with valid signature"); + Ok(known_device) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_frame_padding_rejects_empty() { + let result = strip_frame_padding(Vec::new()); + assert!(matches!( + result, + Err(Error::Transport(TransportError::InvalidFraming)) + )); + } + + #[test] + fn strip_frame_padding_rejects_overlong_padding() { + // Length 1 + declared padding of 5 -> would require subtracting 6 from 1. + let frame = vec![0x05u8]; + let result = strip_frame_padding(frame); + assert!(matches!( + result, + Err(Error::Transport(TransportError::InvalidFraming)) + )); + } + + #[test] + fn strip_frame_padding_strips_normal_padding() { + // 4 bytes of payload, 3 bytes of zero padding, then padding-length 3. + let frame = vec![0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x03]; + let stripped = strip_frame_padding(frame).unwrap(); + assert_eq!(stripped, vec![0xAA, 0xBB, 0xCC, 0xDD]); + } +} diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index 06bb9f5..e4b5267 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -10,6 +10,7 @@ use rand::RngCore; use serde::Serialize; use serde_bytes::ByteArray; use serde_indexed::SerializeIndexed; +use serde_repr::Serialize_repr; use tokio::sync::{broadcast, mpsc, watch}; use tokio::task; use tracing::instrument; @@ -19,7 +20,8 @@ use super::connection_stages::{ MpscUxUpdateSender, ProximityCheckInput, TunnelConnectionInput, UxUpdateSender, }; use super::known_devices::CableKnownDeviceInfoStore; -use super::tunnel::{self, KNOWN_TUNNEL_DOMAINS}; +use super::protocol; +use super::tunnel::KNOWN_TUNNEL_DOMAINS; use super::{channel::CableChannel, channel::ConnectionState, Cable}; use crate::proto::ctap2::cbor; use crate::transport::cable::digit_encode; @@ -35,6 +37,44 @@ pub enum QrCodeOperationHint { MakeCredential, } +/// One of the data transfer channels listed in QR code key 6. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr)] +#[repr(u8)] +pub(crate) enum CableTransportChannel { + WebSocket = 0, + Ble = 1, +} + +/// Which hybrid transport(s) the QR code advertises support for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CableTransports { + /// caBLE v2 only: advertise the cloud-assisted WebSocket tunnel. Omits QR + /// key 6, so the QR stays valid for legacy peers that read key 6 as a + /// `supports_non_discoverable_mc` boolean and would hard-reject a CBOR + /// array (e.g. Google Play services Fido pre-CTAP-2.3). + CloudAssistedOnly, + + /// caBLE v2 + CTAP 2.3 hybrid: advertise both the cloud-assisted WebSocket + /// tunnel and the direct BLE L2CAP data channel via QR key 6. CTAP 2.3- + /// aware peers may open the L2CAP channel; older peers silently ignore + /// key 6 and fall back to the WebSocket tunnel. + CloudAssistedOrLocal, +} + +impl CableTransports { + /// CBOR form of QR key 6. `None` for `CloudAssistedOnly` so a legacy peer + /// doesn't see an unexpected CBOR array where it wants a boolean. + pub(crate) fn to_qr_field(self) -> Option> { + match self { + Self::CloudAssistedOnly => None, + Self::CloudAssistedOrLocal => Some(vec![ + CableTransportChannel::WebSocket, + CableTransportChannel::Ble, + ]), + } + } +} + #[derive(Debug, Clone, SerializeIndexed)] pub struct CableQrCode { // Key 0: a 33-byte, P-256, X9.62, compressed public key. @@ -68,9 +108,13 @@ pub struct CableQrCode { #[serde(index = 0x05)] pub operation_hint: QrCodeOperationHint, + /// Key 6: data transfer channels the client supports (CTAP 2.3 hybrid). + /// Set via [`CableTransports`] at construction time; stored here as a + /// `Vec` for CBOR serialization. `None` omits key 6 entirely so the QR + /// stays valid for caBLE v2 peers that interpret key 6 incompatibly. #[serde(skip_serializing_if = "Option::is_none")] #[serde(index = 0x06)] - pub supports_non_discoverable_mc: Option, + pub(crate) transports: Option>, } impl std::fmt::Display for CableQrCode { @@ -107,14 +151,16 @@ impl CableQrCodeDevice { pub fn new_persistent( hint: QrCodeOperationHint, store: Arc, + transports: CableTransports, ) -> Result { - Self::new(hint, true, Some(store)) + Self::new(hint, true, Some(store), transports) } fn new( hint: QrCodeOperationHint, state_assisted: bool, store: Option>, + transports: CableTransports, ) -> Result { let private_key_scalar = NonZeroScalar::random(&mut OsRng); let private_key = SecretKey::from(private_key_scalar); @@ -133,6 +179,8 @@ impl CableQrCodeDevice { .ok() .map(|t| t.as_secs()); + let transports = transports.to_qr_field(); + Ok(Self { qr_code: CableQrCode { public_key: ByteArray::from(public_key), @@ -140,11 +188,10 @@ impl CableQrCodeDevice { known_tunnel_domains_count: KNOWN_TUNNEL_DOMAINS.len() as u8, current_time: current_unix_time, operation_hint: hint, - state_assisted: Some(state_assisted), - supports_non_discoverable_mc: match hint { - QrCodeOperationHint::MakeCredential => Some(true), - _ => None, - }, + // Chrome convention: omit key 4 when false (presence implies + // caBLE v2.1, absence implies v2.0). + state_assisted: state_assisted.then_some(true), + transports, }, private_key: private_key_scalar, store, @@ -155,8 +202,11 @@ impl CableQrCodeDevice { impl CableQrCodeDevice { /// Generates a QR code, without any known-device store. A device scanning this QR code /// will not be persisted. - pub fn new_transient(hint: QrCodeOperationHint) -> Result { - Self::new(hint, false, None) + pub fn new_transient( + hint: QrCodeOperationHint, + transports: CableTransports, + ) -> Result { + Self::new(hint, false, None, transports) } #[instrument(skip_all, err)] @@ -181,10 +231,6 @@ impl CableQrCodeDevice { } } -unsafe impl Send for CableQrCodeDevice {} - -unsafe impl Sync for CableQrCodeDevice {} - impl Display for CableQrCodeDevice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "CableQrCodeDevice") @@ -225,7 +271,7 @@ impl<'d> Device<'d, Cable, CableChannel> for CableQrCodeDevice { cbor_tx_recv, cbor_rx_send, ); - tunnel::connection(tunnel_input).await; + protocol::connection(tunnel_input).await; ux_sender .set_connection_state(ConnectionState::Terminated) @@ -247,5 +293,45 @@ impl<'d> Device<'d, Cable, CableChannel> for CableQrCodeDevice { // } } -// TODO: unit tests -// https://source.chromium.org/chromium/chromium/src/+/main:device/fido/cable/v2_handshake_unittest.cc +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + // Downstream callers (e.g. credentialsd) move a CableQrCodeDevice across + // tokio::spawn boundaries, so both Send and Sync need to auto-derive. + const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::(); + }; + + #[test] + fn qr_code_omits_key_6_for_cloud_assisted_only() { + let device = CableQrCodeDevice::new_transient( + QrCodeOperationHint::MakeCredential, + CableTransports::CloudAssistedOnly, + ) + .unwrap(); + let bytes = cbor::to_vec(&device.qr_code).unwrap(); + let map: BTreeMap = cbor::from_slice(&bytes).unwrap(); + assert_eq!(map.get(&6), None); + } + + #[test] + fn qr_code_encodes_key_6_for_cloud_assisted_or_local() { + let device = CableQrCodeDevice::new_transient( + QrCodeOperationHint::MakeCredential, + CableTransports::CloudAssistedOrLocal, + ) + .unwrap(); + let bytes = cbor::to_vec(&device.qr_code).unwrap(); + let map: BTreeMap = cbor::from_slice(&bytes).unwrap(); + assert_eq!( + map.get(&6), + Some(&cbor::Value::Array(vec![ + cbor::Value::Integer(0), + cbor::Value::Integer(1), + ])), + ); + } +} diff --git a/libwebauthn/src/transport/cable/tunnel.rs b/libwebauthn/src/transport/cable/tunnel.rs index 35afae0..d1e2c7a 100644 --- a/libwebauthn/src/transport/cable/tunnel.rs +++ b/libwebauthn/src/transport/cable/tunnel.rs @@ -1,32 +1,14 @@ -use std::collections::BTreeMap; -use std::sync::Arc; - -use futures::{SinkExt, StreamExt}; -use hmac::{Hmac, Mac}; -use p256::{ecdh, NonZeroScalar}; -use p256::{PublicKey, SecretKey}; -use serde::Deserialize; -use serde_bytes::ByteBuf; -use serde_cbor_2 as serde_cbor; -use serde_indexed::DeserializeIndexed; +//! WebSocket tunnel-server transport for the caBLE hybrid protocol. use sha2::{Digest, Sha256}; -use snow::{Builder, TransportState}; use tokio::net::TcpStream; -use tokio::sync::mpsc::Sender; use tokio_tungstenite::tungstenite::http::StatusCode; -use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; -use tracing::{debug, error, trace, warn}; +use tracing::{debug, error, trace}; use tungstenite::client::IntoClientRequest; -use super::known_devices::ClientPayload; -use super::known_devices::{CableKnownDeviceInfo, CableKnownDeviceInfoStore}; -use crate::proto::ctap2::cbor::{self, CborRequest, CborResponse, Value}; -use crate::proto::ctap2::{Ctap2CommandCode, Ctap2GetInfoResponse}; -use crate::transport::cable::connection_stages::TunnelConnectionInput; -use crate::transport::cable::known_devices::CableKnownDeviceId; +use super::protocol::CableTunnelConnectionType; +use crate::proto::ctap2::cbor; use crate::transport::error::TransportError; -use crate::webauthn::error::Error; fn ensure_rustls_crypto_provider() { use std::sync::Once; @@ -40,97 +22,6 @@ pub(crate) const KNOWN_TUNNEL_DOMAINS: &[&str] = &["cable.ua5v.com", "cable.auth const SHA_INPUT: &[u8] = b"caBLEv2 tunnel server domain"; const BASE32_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; const TLDS: &[&str] = &[".com", ".org", ".net", ".info"]; -const P256_X962_LENGTH: usize = 65; -const MAX_CBOR_SIZE: usize = 1024 * 1024; -const PADDING_GRANULARITY: usize = 32; - -const CABLE_PROLOGUE_STATE_ASSISTED: &[u8] = &[0u8]; -const CABLE_PROLOGUE_QR_INITIATED: &[u8] = &[1u8]; - -#[derive(Debug, Clone)] -struct CableTunnelMessage { - message_type: CableTunnelMessageType, - payload: ByteBuf, -} - -impl CableTunnelMessage { - pub fn new(message_type: CableTunnelMessageType, payload: &[u8]) -> Self { - Self { - message_type, - payload: ByteBuf::from(payload.to_vec()), - } - } - pub fn from_slice(slice: &[u8]) -> Result { - let (type_byte, payload) = slice - .split_first() - .ok_or(Error::Transport(TransportError::InvalidFraming))?; - if payload.is_empty() { - return Err(Error::Transport(TransportError::InvalidFraming)); - } - - let message_type = match *type_byte { - 0 => CableTunnelMessageType::Shutdown, - 1 => CableTunnelMessageType::Ctap, - 2 => CableTunnelMessageType::Update, - _ => { - return Err(Error::Transport(TransportError::InvalidFraming)); - } - }; - - Ok(Self { - message_type, - payload: ByteBuf::from(payload.to_vec()), - }) - } - - pub fn to_vec(&self) -> Vec { - let mut vec = Vec::new(); - // TODO: multiple versions - vec.push(self.message_type as u8); - vec.extend(self.payload.iter()); - vec - } -} - -#[derive(Clone, Debug, DeserializeIndexed)] -struct CableInitialMessage { - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(index = 0x00)] - pub _padding: Option, - - #[serde(index = 0x01)] - pub info: ByteBuf, - - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(index = 0x03)] - pub _supported_features: Option>, -} - -#[derive(Clone, Debug)] -pub(crate) struct CableLinkingInfo { - /// Used by the tunnel to identify the authenticator (eg. Android FCM token) - pub contact_id: Vec, - /// Used by the authenticator to identify the client platform - pub link_id: Vec, - /// Shared secret between authenticator and client platform - pub link_secret: Vec, - /// Authenticator's public key, X9.62 uncompressed format - pub authenticator_public_key: Vec, - /// User-friendly name of the authenticator - pub authenticator_name: String, - /// HMAC of the handshake hash (Noise's channel binding value) using the - /// shared secret (link_secret) as key - #[allow(dead_code)] - pub handshake_signature: Vec, -} - -#[repr(u8)] -#[derive(Debug, Clone, Copy, Deserialize)] -enum CableTunnelMessageType { - Shutdown = 0, - Ctap = 1, - Update = 2, -} pub fn decode_tunnel_server_domain(encoded: u16) -> Option { if encoded < 256 { @@ -164,47 +55,6 @@ pub fn decode_tunnel_server_domain(encoded: u16) -> Option { Some(ret) } -#[derive(Clone)] -pub(crate) enum CableTunnelConnectionType { - QrCode { - routing_id: String, - tunnel_id: String, - private_key: NonZeroScalar, - }, - KnownDevice { - contact_id: String, - authenticator_public_key: Vec, - client_payload: ClientPayload, - }, -} - -impl std::fmt::Debug for CableTunnelConnectionType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::QrCode { - routing_id, - tunnel_id, - private_key: _, - } => f - .debug_struct("QrCode") - .field("routing_id", routing_id) - .field("tunnel_id", tunnel_id) - .field("private_key", &"[REDACTED]") - .finish(), - Self::KnownDevice { - contact_id, - authenticator_public_key, - client_payload, - } => f - .debug_struct("KnownDevice") - .field("contact_id", contact_id) - .field("authenticator_public_key", authenticator_public_key) - .field("client_payload", client_payload) - .finish(), - } - } -} - pub(crate) async fn connect( tunnel_domain: &str, connection_type: &CableTunnelConnectionType, @@ -265,552 +115,6 @@ pub(crate) async fn connect( Ok(ws_stream) } -pub(crate) struct TunnelNoiseState { - pub transport_state: TransportState, - #[allow(dead_code)] - pub handshake_hash: Vec, -} - -pub(crate) async fn do_handshake( - ws_stream: &mut WebSocketStream>, - psk: [u8; 32], - connection_type: &CableTunnelConnectionType, -) -> Result { - let noise_handshake = match connection_type { - CableTunnelConnectionType::QrCode { private_key, .. } => { - let local_private_key = private_key.to_owned().to_bytes(); - Builder::new("Noise_KNpsk0_P256_AESGCM_SHA256".parse()?) - .prologue(CABLE_PROLOGUE_QR_INITIATED)? - .local_private_key(local_private_key.as_slice())? - .psk(0, &psk)? - .build_initiator() - } - CableTunnelConnectionType::KnownDevice { - authenticator_public_key, - .. - } => Builder::new("Noise_NKpsk0_P256_AESGCM_SHA256".parse()?) - .prologue(CABLE_PROLOGUE_STATE_ASSISTED)? - .remote_public_key(authenticator_public_key)? - .psk(0, &psk)? - .build_initiator(), - }; - - // Build the Noise handshake as the initiator - let mut noise_handshake = match noise_handshake { - Ok(handshake) => handshake, - Err(e) => { - error!(?e, "Failed to build Noise handshake"); - return Err(TransportError::ConnectionFailed); - } - }; - - let mut initial_msg_buffer = vec![0u8; 1024]; - let initial_msg_len = match noise_handshake.write_message(&[], &mut initial_msg_buffer) { - Ok(msg_len) => msg_len, - Err(e) => { - error!(?e, "Failed to write initial handshake message"); - return Err(TransportError::ConnectionFailed); - } - }; - - let initial_msg: Vec = initial_msg_buffer - .get(..initial_msg_len) - .map(<[u8]>::to_vec) - .ok_or(TransportError::ConnectionFailed)?; - trace!( - { handshake = ?initial_msg }, - "Sending initial handshake message" - ); - - if let Err(e) = ws_stream.send(Message::Binary(initial_msg.into())).await { - error!(?e, "Failed to send initial handshake message"); - return Err(TransportError::ConnectionFailed); - } - debug!("Sent initial handshake message"); - - // Read the response from the server and process it - let response = match ws_stream.next().await { - Some(Ok(Message::Binary(response))) => { - debug!(response_len = response.len(), "Received handshake response"); - trace!(?response); - response - } - - Some(Ok(msg)) => { - error!(?msg, "Unexpected message type received"); - return Err(TransportError::ConnectionFailed); - } - Some(Err(e)) => { - error!(?e, "Failed to read handshake response"); - return Err(TransportError::ConnectionFailed); - } - None => { - error!("Connection was closed before handshake was complete"); - return Err(TransportError::ConnectionFailed); - } - }; - - /* output: - keys trafficKeys, - handshakeHash [32]byte) { - */ - if response.len() < P256_X962_LENGTH { - error!( - { len = response.len() }, - "Peer handshake message is too short" - ); - return Err(TransportError::ConnectionFailed); - } - - let mut payload = [0u8; 1024]; - let payload_len = match noise_handshake.read_message(&response, &mut payload) { - Ok(len) => len, - Err(e) => { - error!(?e, "Failed to read handshake response message"); - return Err(TransportError::ConnectionFailed); - } - }; - - debug!( - { handshake = ?payload.get(..payload_len) }, - "Received handshake response" - ); - - if !noise_handshake.is_handshake_finished() { - error!("Handshake did not complete"); - return Err(TransportError::ConnectionFailed); - } - - Ok(TunnelNoiseState { - handshake_hash: noise_handshake.get_handshake_hash().to_vec(), - transport_state: noise_handshake.into_transport_mode()?, - }) -} - -pub(crate) async fn connection(mut input: TunnelConnectionInput) { - // Fetch the inital message - let get_info_response_serialized: Vec = match input.ws_stream.next().await { - Some(Ok(message)) => match connection_recv_initial(message, &mut input.noise_state).await { - Ok(initial) => initial, - Err(e) => { - error!(?e, "Failed to process initial message"); - return; - } - }, - Some(Err(e)) => { - error!(?e, "Failed to read initial message"); - return; - } - None => { - error!("Connection closed before initial message was received"); - return; - } - }; - debug!(?get_info_response_serialized, "Received initial message"); - - loop { - // Wait for a message on ws_stream, or a request to send on cbor_rx_send - tokio::select! { - Some(message) = input.ws_stream.next() => { - match message { - Err(e) => { - error!(?e, "Failed to read encrypted CBOR message"); - return; - } - Ok(message) => { - debug!("Received WSS message"); - trace!(?message); - let _ = connection_recv(&input.connection_type, &input.tunnel_domain, &input.known_device_store, message, &input.cbor_rx_send, &mut input.noise_state).await; - } - }; - } - Some(request) = input.cbor_tx_recv.recv() => { - match request.command { - // Optimisation: respond to GetInfo requests immediately with the cached response - Ctap2CommandCode::AuthenticatorGetInfo => { - debug!("Responding to GetInfo request with cached response"); - let response = CborResponse::new_success_from_slice(&get_info_response_serialized); - let _ = input.cbor_rx_send.send(response).await; - } - _ => { - debug!(?request.command, "Sending CBOR request"); - let _ = connection_send(request, &mut input.ws_stream, &mut input.noise_state).await; - } - } - } - else => { - // The sender has been dropped, so we should exit - debug!("Sender dropped, closing connection"); - return; - } - }; - } -} - -async fn connection_send( - request: CborRequest, - ws_stream: &mut WebSocketStream>, - noise_state: &mut TunnelNoiseState, -) -> Result<(), Error> { - debug!("Sending CBOR request"); - trace!(?request); - - let cbor_request = request - .raw_long() - .map_err(|e| TransportError::IoError(e.kind()))?; - if cbor_request.len() > MAX_CBOR_SIZE { - error!( - cbor_request_len = cbor_request.len(), - "CBOR request too large" - ); - return Err(Error::Transport(TransportError::InvalidFraming)); - } - trace!(?cbor_request, cbor_request_len = cbor_request.len()); - - let extra_bytes = PADDING_GRANULARITY - (cbor_request.len() % PADDING_GRANULARITY); - let padded_len = cbor_request.len() + extra_bytes; - - let mut padded_cbor_request = cbor_request.clone(); - padded_cbor_request.resize(padded_len, 0u8); - if let Some(last) = padded_cbor_request.last_mut() { - *last = (extra_bytes - 1) as u8; - } - - let frame = CableTunnelMessage::new(CableTunnelMessageType::Ctap, &padded_cbor_request); - let frame_serialized = frame.to_vec(); - trace!(?frame_serialized); - - let mut encrypted_frame = vec![0u8; MAX_CBOR_SIZE + 1]; - match noise_state - .transport_state - .write_message(&frame_serialized, &mut encrypted_frame) - { - Ok(size) => { - encrypted_frame.resize(size, 0u8); - } - Err(e) => { - error!(?e, "Failed to encrypt frame"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - } - - debug!("Sending encrypted frame"); - trace!(?encrypted_frame); - - if let Err(e) = ws_stream.send(encrypted_frame.into()).await { - error!(?e, "Failed to send encrypted frame"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - - Ok(()) -} - -async fn connection_recv_binary_frame(message: Message) -> Result>, Error> { - match message { - Message::Ping(_) | Message::Pong(_) => { - debug!("Received keepalive message"); - Ok(None) - } - Message::Close(close_frame) => { - debug!(?close_frame, "Received close frame"); - Err(Error::Transport(TransportError::ConnectionFailed)) - } - Message::Binary(encrypted_frame) => { - let encrypted_frame: Vec = encrypted_frame.into(); - debug!( - frame_len = encrypted_frame.len(), - "Received encrypted CBOR response" - ); - trace!(?encrypted_frame); - Ok(Some(encrypted_frame)) - } - _ => { - error!(?message, "Unexpected message type received"); - Err(Error::Transport(TransportError::ConnectionFailed)) - } - } -} - -/// Strip the trailing padding-length byte and `padding_len` bytes of padding -/// from a decrypted Noise transport frame, returning `InvalidFraming` on an -/// empty plaintext or a declared padding length that exceeds the frame. -fn strip_frame_padding(mut decrypted_frame: Vec) -> Result, Error> { - let padding_len = match decrypted_frame.last() { - Some(&b) => b as usize, - None => { - error!("Decrypted frame is empty; cannot read padding length"); - return Err(Error::Transport(TransportError::InvalidFraming)); - } - }; - let new_len = decrypted_frame - .len() - .checked_sub(padding_len + 1) - .ok_or_else(|| { - error!( - frame_len = decrypted_frame.len(), - padding_len, "Padding length exceeds frame length" - ); - Error::Transport(TransportError::InvalidFraming) - })?; - decrypted_frame.truncate(new_len); - Ok(decrypted_frame) -} - -async fn decrypt_frame( - encrypted_frame: Vec, - noise_state: &mut TunnelNoiseState, -) -> Result, Error> { - let mut decrypted_frame = vec![0u8; MAX_CBOR_SIZE]; - match noise_state - .transport_state - .read_message(&encrypted_frame, &mut decrypted_frame) - { - Ok(size) => { - debug!(decrypted_frame_len = size, "Decrypted CBOR response"); - decrypted_frame.resize(size, 0u8); - trace!(?decrypted_frame); - } - Err(e) => { - error!(?e, "Failed to decrypt CBOR response"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - } - - let decrypted_frame = strip_frame_padding(decrypted_frame)?; - trace!( - ?decrypted_frame, - decrypted_frame_len = decrypted_frame.len(), - "Trimmed padding" - ); - - Ok(decrypted_frame) -} - -async fn connection_recv_initial( - message: Message, - noise_state: &mut TunnelNoiseState, -) -> Result, Error> { - let Some(encrypted_frame) = connection_recv_binary_frame(message).await? else { - return Err(Error::Transport(TransportError::ConnectionFailed)); - }; - - let decrypted_frame = decrypt_frame(encrypted_frame, noise_state).await?; - - let initial_message: CableInitialMessage = match cbor::from_slice(&decrypted_frame) { - Ok(initial_message) => initial_message, - Err(e) => { - error!(?e, "Failed to decode initial message"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - }; - - let _: Ctap2GetInfoResponse = match cbor::from_slice(&initial_message.info) { - Ok(get_info_response) => get_info_response, - Err(e) => { - error!(?e, "Failed to decode GetInfo response"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - }; - - Ok(initial_message.info.to_vec()) -} - -async fn connection_recv_update(message: &[u8]) -> Result, Error> { - // TODO(#66): Android adds a 999-key to the end the message, which is not part of the standard. - // For now, we parse the message to a map and manuually import fields. - - let update_message: BTreeMap = match serde_cbor::from_slice(message) { - Ok(update_message) => update_message, - Err(e) => { - error!(?e, "Failed to decode update message"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - }; - - let Some(Value::Map(linking_info_map)) = update_message.get(&Value::Integer(0x01)) else { - warn!("Empty linking info map"); - return Ok(None); - }; - - trace!(?linking_info_map); - - let Some(Value::Bytes(contact_id)) = linking_info_map.get(&Value::Integer(0x01)) else { - warn!("Missing contact ID"); - return Ok(None); - }; - - let Some(Value::Bytes(link_id)) = linking_info_map.get(&Value::Integer(0x02)) else { - warn!("Missing link ID"); - return Ok(None); - }; - - let Some(Value::Bytes(link_secret)) = linking_info_map.get(&Value::Integer(0x03)) else { - warn!("Missing link secret"); - return Ok(None); - }; - - let Some(Value::Bytes(authenticator_public_key)) = linking_info_map.get(&Value::Integer(0x04)) - else { - warn!("Missing authenticator public key"); - return Ok(None); - }; - - let Some(Value::Text(authenticator_name)) = linking_info_map.get(&Value::Integer(0x05)) else { - warn!("Missing authenticator name"); - return Ok(None); - }; - - let Some(Value::Bytes(handshake_signature)) = linking_info_map.get(&Value::Integer(0x06)) - else { - warn!("Missing handshake_signature"); - return Ok(None); - }; - - let linking_info = CableLinkingInfo { - contact_id: contact_id.clone(), - link_id: link_id.clone(), - link_secret: link_secret.clone(), - authenticator_public_key: authenticator_public_key.clone(), - authenticator_name: authenticator_name.clone(), - handshake_signature: handshake_signature.clone(), - }; - - Ok(Some(linking_info)) -} - -async fn connection_recv( - connection_type: &CableTunnelConnectionType, - tunnel_domain: &str, - known_device_store: &Option>, - message: Message, - cbor_rx_send: &Sender, - noise_state: &mut TunnelNoiseState, -) -> Result<(), Error> { - let Some(encrypted_frame) = connection_recv_binary_frame(message).await? else { - return Ok(()); - }; - - let decrypted_frame = decrypt_frame(encrypted_frame, noise_state).await?; - - // TODO handle the decrypted frame - let cable_message: CableTunnelMessage = match CableTunnelMessage::from_slice(&decrypted_frame) { - Ok(cable_message) => cable_message, - Err(e) => { - error!(?e, "Failed to decode CABLE tunnel message"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - }; - - trace!(?cable_message); - match cable_message.message_type { - CableTunnelMessageType::Shutdown => { - // Unexpected shutdown message - error!("Received unexpected shutdown message"); - return Err(Error::Transport(TransportError::ConnectionFailed)); - } - CableTunnelMessageType::Ctap => { - // Handle the CTAP message - let cbor_response: CborResponse = (&cable_message.payload.to_vec()) - .try_into() - .or(Err(TransportError::InvalidFraming))?; - - debug!("Received CBOR response"); - trace!(?cbor_response); - cbor_rx_send - .send(cbor_response) - .await - .or(Err(TransportError::ConnectionFailed))?; - } - CableTunnelMessageType::Update => { - // Handle the update message - let maybe_update_message: Option = - connection_recv_update(&cable_message.payload).await?; - - let Some(linking_info) = maybe_update_message else { - warn!("Ignoring update message without linking info"); - return Ok(()); - }; - - let CableTunnelConnectionType::QrCode { private_key, .. } = connection_type else { - warn!("Ignoring update message for non-QR code connection"); - return Ok(()); - }; - - debug!("Received update message with linking info"); - trace!(?linking_info); - - let device_id: CableKnownDeviceId = (&linking_info).into(); - match known_device_store { - Some(store) => { - match parse_known_device(private_key, tunnel_domain, &linking_info, noise_state) - { - Ok(known_device) => { - debug!(?device_id, "Updating known device"); - trace!(?known_device); - store.put_known_device(&device_id, &known_device).await; - } - Err(e) => { - error!( - ?e, - "Invalid update message from authenticator, forgetting device" - ); - store.delete_known_device(&device_id).await; - return Err(Error::Transport(TransportError::TransportUnavailable)); - } - } - } - None => { - warn!("Ignoring update message without a device store"); - } - }; - } - }; - - Ok(()) -} - -/// Validation requires a shared key computed on the QR code ephemeral identity key (private_key here). -/// We're currently unable to validate the signature on linking information received for state-assisted transactions, -/// so these should be discarded. This is the same Chrome currently does, although it may change in future spec versions. -/// See: https://github.com/chromium/chromium/blob/88e250200e59daf52554bcc74870138143a830c4/device/fido/cable/fido_tunnel_device.cc#L547-L549 -fn parse_known_device( - private_key: &NonZeroScalar, - tunnel_domain: &str, - linking_info: &CableLinkingInfo, - noise_state: &TunnelNoiseState, -) -> Result { - let known_device = CableKnownDeviceInfo::new(tunnel_domain, linking_info)?; - let secret_key = SecretKey::from(private_key); - - let Ok(authenticator_public_key) = - PublicKey::from_sec1_bytes(&linking_info.authenticator_public_key) - else { - error!("Failed to parse public key."); - return Err(Error::Transport(TransportError::InvalidKey)); - }; - - let shared_secret: Vec = ecdh::diffie_hellman( - secret_key.to_nonzero_scalar(), - authenticator_public_key.as_affine(), - ) - .raw_secret_bytes() - .to_vec(); - - let mut hmac = Hmac::::new_from_slice(&shared_secret) - .map_err(|_| Error::Transport(TransportError::InvalidKey))?; - hmac.update(&noise_state.handshake_hash); - let expected_mac = hmac.finalize().into_bytes().to_vec(); - - if expected_mac != linking_info.handshake_signature { - error!("Invalid handshake signature, rejecting update message"); - trace!(?expected_mac, ?linking_info.handshake_signature); - return Err(Error::Transport(TransportError::InvalidSignature)); - } - - debug!("Parsed known device with valid signature"); - Ok(known_device) -} - #[cfg(test)] mod tests { use super::*; @@ -827,32 +131,4 @@ mod tests { } // TODO: test the non-known case - - #[test] - fn strip_frame_padding_rejects_empty() { - let result = strip_frame_padding(Vec::new()); - assert!(matches!( - result, - Err(Error::Transport(TransportError::InvalidFraming)) - )); - } - - #[test] - fn strip_frame_padding_rejects_overlong_padding() { - // Length 1 + declared padding of 5 -> would require subtracting 6 from 1. - let frame = vec![0x05u8]; - let result = strip_frame_padding(frame); - assert!(matches!( - result, - Err(Error::Transport(TransportError::InvalidFraming)) - )); - } - - #[test] - fn strip_frame_padding_strips_normal_padding() { - // 4 bytes of payload, 3 bytes of zero padding, then padding-length 3. - let frame = vec![0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x00, 0x00, 0x03]; - let stripped = strip_frame_padding(frame).unwrap(); - assert_eq!(stripped, vec![0xAA, 0xBB, 0xCC, 0xDD]); - } }