From b93c2c6f4989edf8409d0064ef56474322e8f34c Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:37:52 +0100 Subject: [PATCH 01/17] refactor(cable): introduce CableDataChannel transport abstraction Adds a message-oriented duplex channel trait so the Noise handshake and encrypted CTAP framing can run over any transport, plus a WebSocket implementation. Unused until the protocol layer is moved onto it. --- .../src/transport/cable/data_channel.rs | 68 +++++++++++++++++++ libwebauthn/src/transport/cable/mod.rs | 1 + 2 files changed, 69 insertions(+) create mode 100644 libwebauthn/src/transport/cable/data_channel.rs diff --git a/libwebauthn/src/transport/cable/data_channel.rs b/libwebauthn/src/transport/cable/data_channel.rs new file mode 100644 index 00000000..a6cd5d3d --- /dev/null +++ b/libwebauthn/src/transport/cable/data_channel.rs @@ -0,0 +1,68 @@ +//! Transport-agnostic message channel for the hybrid transport. +// Unused until the protocol refactor wires it in. +#![allow(dead_code)] + +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/mod.rs b/libwebauthn/src/transport/cable/mod.rs index 118e5a87..b12a7605 100644 --- a/libwebauthn/src/transport/cable/mod.rs +++ b/libwebauthn/src/transport/cable/mod.rs @@ -1,6 +1,7 @@ use std::fmt::Display; mod crypto; +mod data_channel; mod digit_encode; pub mod advertisement; From fc9a1d3e06078ca927ef0de4485e073ff0974005 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:58:37 +0100 Subject: [PATCH 02/17] refactor(cable): move Noise protocol onto the CableDataChannel abstraction Lifts the Noise handshake, CableTunnelMessage framing, padding, the connection loop and post-handshake/update parsing out of tunnel.rs into a new protocol.rs, generic over a CableDataChannel instead of a WebSocketStream. tunnel.rs keeps the WebSocket connect and tunnel-domain decoding. connection_stages.rs carries a Box. caBLE over WebSocket behaves identically; the abstraction lets a future data channel (BLE L2CAP) plug in. --- .../src/transport/cable/connection_stages.rs | 38 +- .../src/transport/cable/data_channel.rs | 3 - .../src/transport/cable/known_devices.rs | 4 +- libwebauthn/src/transport/cable/mod.rs | 1 + libwebauthn/src/transport/cable/protocol.rs | 695 +++++++++++++++++ .../src/transport/cable/qr_code_device.rs | 5 +- libwebauthn/src/transport/cable/tunnel.rs | 732 +----------------- 7 files changed, 727 insertions(+), 751 deletions(-) create mode 100644 libwebauthn/src/transport/cable/protocol.rs diff --git a/libwebauthn/src/transport/cable/connection_stages.rs b/libwebauthn/src/transport/cable/connection_stages.rs index c2e844fd..35f1f08f 100644 --- a/libwebauthn/src/transport/cable/connection_stages.rs +++ b/libwebauthn/src/transport/cable/connection_stages.rs @@ -1,14 +1,15 @@ use async_trait::async_trait; use tokio::sync::{broadcast, mpsc, watch}; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tracing::{debug, error, 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::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; @@ -70,7 +71,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 { @@ -110,15 +111,14 @@ impl ConnectionInput { } } -#[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 +133,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 +149,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 +158,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 +168,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 +185,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, @@ -270,10 +270,11 @@ pub(crate) async fn connection_stage( .await; let ws_stream = tunnel::connect(&input.tunnel_domain, &input.connection_type).await?; + let data_channel: Box = Box::new(WebSocketDataChannel::new(ws_stream)); debug!("Connection stage completed successfully"); Ok(ConnectionOutput { - ws_stream, + data_channel, connection_type: input.connection_type, tunnel_domain: input.tunnel_domain, }) @@ -290,9 +291,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 +305,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 +313,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/data_channel.rs b/libwebauthn/src/transport/cable/data_channel.rs index a6cd5d3d..9f28c1d4 100644 --- a/libwebauthn/src/transport/cable/data_channel.rs +++ b/libwebauthn/src/transport/cable/data_channel.rs @@ -1,7 +1,4 @@ //! Transport-agnostic message channel for the hybrid transport. -// Unused until the protocol refactor wires it in. -#![allow(dead_code)] - use async_trait::async_trait; use futures::{SinkExt, StreamExt}; use tokio::net::TcpStream; diff --git a/libwebauthn/src/transport/cable/known_devices.rs b/libwebauthn/src/transport/cable/known_devices.rs index 5782d81d..fdcfef35 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/mod.rs b/libwebauthn/src/transport/cable/mod.rs index b12a7605..1d605a2b 100644 --- a/libwebauthn/src/transport/cable/mod.rs +++ b/libwebauthn/src/transport/cable/mod.rs @@ -3,6 +3,7 @@ use std::fmt::Display; mod crypto; mod data_channel; mod digit_encode; +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 00000000..b15dccd1 --- /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 06bb9f55..fc878f7c 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -19,7 +19,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; @@ -225,7 +226,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) diff --git a/libwebauthn/src/transport/cable/tunnel.rs b/libwebauthn/src/transport/cable/tunnel.rs index 35afae05..d1e2c7ab 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]); - } } From 80256a00a6a2e20fa9d4144f9792db8cb66f4eb6 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:48:05 +0100 Subject: [PATCH 03/17] feat(cable): parse the CTAP 2.3 hybrid advertisement suffix Accept BLE service data of length >= 20: trial-decrypt only the first 20 bytes, then parse any remainder as the CTAP 2.3 PXP advertisement suffix, a CBOR map of transport_channel_identifier to channel_extra. AdvertisementSuffix exposes ble_psm() for the BLE channel (id 1). Unknown channel ids and out-of-range PSMs are ignored; a malformed suffix is logged and treated as absent without failing the advert. Both touched modules gain a module-scoped deny(indexing_slicing) and move to checked slice access. --- .../src/transport/cable/advertisement.rs | 129 ++++++++++++++++-- libwebauthn/src/transport/cable/crypto.rs | 85 ++++++++++-- 2 files changed, 193 insertions(+), 21 deletions(-) diff --git a/libwebauthn/src/transport/cable/advertisement.rs b/libwebauthn/src/transport/cable/advertisement.rs index e0ab7fcd..6938efa1 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,57 @@ 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. + #[allow(dead_code)] // Consumed by the BLE L2CAP connection path (not yet wired). + 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 +104,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 +135,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/crypto.rs b/libwebauthn/src/transport/cable/crypto.rs index be9a9927..bb8dc67d 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() { From cf241b67d3df1ebead1b6ae0293b9d5860566220 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 20:04:00 +0100 Subject: [PATCH 04/17] feat(cable): use QR key 6 for the CTAP 2.3 hybrid transport-channels list The CTAP 2.3 hybrid draft assigns QR code key 6 to the array of data transfer channels the client supports (0 = WebSocket, 1 = BLE). The previous code used key 6 for a non-standard supports_non_discoverable_mc flag, which is not in the spec and is removed here. The list is set to [WebSocket] for now; BLE is added once the L2CAP channel is wired in. --- .../src/transport/cable/qr_code_device.rs | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index fc878f7c..c5444407 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; @@ -36,6 +37,14 @@ pub enum QrCodeOperationHint { MakeCredential, } +/// A data transfer channel the client supports, as listed in QR code key 6. +#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr)] +#[repr(u8)] +pub enum CableTransportChannel { + WebSocket = 0, + Ble = 1, +} + #[derive(Debug, Clone, SerializeIndexed)] pub struct CableQrCode { // Key 0: a 33-byte, P-256, X9.62, compressed public key. @@ -69,9 +78,9 @@ pub struct CableQrCode { #[serde(index = 0x05)] pub operation_hint: QrCodeOperationHint, - #[serde(skip_serializing_if = "Option::is_none")] + /// Key 6: data transfer channels the client supports (0 = WebSocket, 1 = BLE). #[serde(index = 0x06)] - pub supports_non_discoverable_mc: Option, + pub transports: Vec, } impl std::fmt::Display for CableQrCode { @@ -142,10 +151,7 @@ impl CableQrCodeDevice { current_time: current_unix_time, operation_hint: hint, state_assisted: Some(state_assisted), - supports_non_discoverable_mc: match hint { - QrCodeOperationHint::MakeCredential => Some(true), - _ => None, - }, + transports: vec![CableTransportChannel::WebSocket], }, private_key: private_key_scalar, store, @@ -248,5 +254,19 @@ 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; + + #[test] + fn qr_code_encodes_transport_channels_at_key_6() { + let device = CableQrCodeDevice::new_transient(QrCodeOperationHint::MakeCredential).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)])), + ); + } +} From a628068be0208e4151beb2dcbb7f504b5a3a2490 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:46:54 +0100 Subject: [PATCH 05/17] feat(cable): add bluer-backed L2CAP data channel Adds bluer (l2cap feature only, no D-Bus) and an L2capDataChannel implementing CableDataChannel over an insecure L2CAP CoC for PXP. CRLF message framing with a cancel-safe read buffer. Not yet wired into the connection flow, so the type is dead_code for now. --- Cargo.lock | 68 +++++++ libwebauthn/Cargo.toml | 1 + libwebauthn/src/transport/cable/l2cap.rs | 218 +++++++++++++++++++++++ libwebauthn/src/transport/cable/mod.rs | 1 + 4 files changed, 288 insertions(+) create mode 100644 libwebauthn/src/transport/cable/l2cap.rs diff --git a/Cargo.lock b/Cargo.lock index 5c00153a..71a43831 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/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 6e90cf13..41b6d091 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 } diff --git a/libwebauthn/src/transport/cable/l2cap.rs b/libwebauthn/src/transport/cable/l2cap.rs new file mode 100644 index 00000000..5a17aa65 --- /dev/null +++ b/libwebauthn/src/transport/cable/l2cap.rs @@ -0,0 +1,218 @@ +//! [`CableDataChannel`] over a direct BLE L2CAP connection-oriented channel. +// Unused until the protocol refactor wires it in. +#![allow(dead_code)] + +use std::str::FromStr; + +use async_trait::async_trait; +use btleplug::api::{AddressType, BDAddr}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{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]; + +/// [`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 + /// ([`SecurityLevel::Sdp`]) L2CAP CoC, taking the peer's btleplug address. + pub(crate) async fn connect( + addr: BDAddr, + addr_type: Option, + psm: u16, + ) -> Result { + let (addr, addr_type) = bdaddr_to_bluer(addr, addr_type)?; + + let socket = bluer::l2cap::Socket::::new_stream().map_err(|e| { + error!(?e, "Failed to create L2CAP stream socket"); + TransportError::IoError(e.kind()) + })?; + socket + .bind(bluer::l2cap::SocketAddr::any_le()) + .map_err(|e| { + error!(?e, "Failed to bind L2CAP socket"); + TransportError::IoError(e.kind()) + })?; + // Insecure CoC: security must be set before connect(). + socket + .set_security(bluer::l2cap::Security { + level: bluer::l2cap::SecurityLevel::Sdp, + key_size: 0, + }) + .map_err(|e| { + error!(?e, "Failed to set L2CAP security level"); + TransportError::IoError(e.kind()) + })?; + let stream = socket + .connect(bluer::l2cap::SocketAddr::new(addr, addr_type, psm)) + .await + .map_err(|e| { + error!(?e, %addr, psm, "Failed to connect L2CAP CoC"); + TransportError::ConnectionFailed + })?; + + Ok(Self { + stream, + read_buf: Vec::new(), + }) + } +} + +#[async_trait] +impl CableDataChannel for L2capDataChannel { + async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> { + // NOTE: CRLF-terminating binary ciphertext is ambiguous in the CTAP 2.3 hybrid draft + // and may need revisiting against real hardware. + 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 = self.stream.read(&mut chunk).await.map_err(|e| { + error!(?e, "Failed to read L2CAP message"); + 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 1d605a2b..d579facc 100644 --- a/libwebauthn/src/transport/cable/mod.rs +++ b/libwebauthn/src/transport/cable/mod.rs @@ -3,6 +3,7 @@ use std::fmt::Display; mod crypto; mod data_channel; mod digit_encode; +mod l2cap; mod protocol; pub mod advertisement; From b28770bc4e27995b2b243dda1b4fa32f618240b4 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 20:11:52 +0100 Subject: [PATCH 06/17] feat(cable): connect over BLE L2CAP when the CMHD offers it connection_stage now inspects the decrypted advertisement suffix: if it carries a BLE L2CAP PSM, it opens an L2capDataChannel to the peripheral discovered during the proximity check, falling back to the WebSocket tunnel if that fails. The QR code advertises both transports. Known devices stay on WebSocket, as state-assisted has no BLE channel negotiation in the CTAP 2.3 hybrid draft. --- .../src/transport/cable/advertisement.rs | 1 - .../src/transport/cable/connection_stages.rs | 64 ++++++++++++++++--- libwebauthn/src/transport/cable/l2cap.rs | 3 - .../src/transport/cable/qr_code_device.rs | 7 +- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/libwebauthn/src/transport/cable/advertisement.rs b/libwebauthn/src/transport/cable/advertisement.rs index 6938efa1..1e21abba 100644 --- a/libwebauthn/src/transport/cable/advertisement.rs +++ b/libwebauthn/src/transport/cable/advertisement.rs @@ -39,7 +39,6 @@ impl AdvertisementSuffix { } /// L2CAP server PSM for the BLE channel, if advertised and in `u16` range. - #[allow(dead_code)] // Consumed by the BLE L2CAP connection path (not yet wired). pub fn ble_psm(&self) -> Option { match self.channels.get(&TRANSPORT_CHANNEL_BLE) { Some(Value::Integer(psm)) => u16::try_from(*psm).ok(), diff --git a/libwebauthn/src/transport/cable/connection_stages.rs b/libwebauthn/src/transport/cable/connection_stages.rs index 35f1f08f..24ed19f7 100644 --- a/libwebauthn/src/transport/cable/connection_stages.rs +++ b/libwebauthn/src/transport/cable/connection_stages.rs @@ -1,12 +1,14 @@ +use ::btleplug::api::{AddressType, BDAddr}; use async_trait::async_trait; use tokio::sync::{broadcast, mpsc, watch}; -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; @@ -46,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 { @@ -79,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, }) } @@ -107,6 +132,7 @@ impl ConnectionInput { Self { tunnel_domain: known_device.device_info.tunnel_domain.clone(), connection_type, + ble: None, } } } @@ -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,8 +292,7 @@ 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: Box = Box::new(WebSocketDataChannel::new(ws_stream)); + let data_channel = connect_data_channel(&input).await?; debug!("Connection stage completed successfully"); Ok(ConnectionOutput { @@ -280,6 +302,32 @@ pub(crate) async fn connection_stage( }) } +/// 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, diff --git a/libwebauthn/src/transport/cable/l2cap.rs b/libwebauthn/src/transport/cable/l2cap.rs index 5a17aa65..20f0f802 100644 --- a/libwebauthn/src/transport/cable/l2cap.rs +++ b/libwebauthn/src/transport/cable/l2cap.rs @@ -1,7 +1,4 @@ //! [`CableDataChannel`] over a direct BLE L2CAP connection-oriented channel. -// Unused until the protocol refactor wires it in. -#![allow(dead_code)] - use std::str::FromStr; use async_trait::async_trait; diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index c5444407..4226d831 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -151,7 +151,7 @@ impl CableQrCodeDevice { current_time: current_unix_time, operation_hint: hint, state_assisted: Some(state_assisted), - transports: vec![CableTransportChannel::WebSocket], + transports: vec![CableTransportChannel::WebSocket, CableTransportChannel::Ble], }, private_key: private_key_scalar, store, @@ -266,7 +266,10 @@ mod tests { let map: BTreeMap = cbor::from_slice(&bytes).unwrap(); assert_eq!( map.get(&6), - Some(&cbor::Value::Array(vec![cbor::Value::Integer(0)])), + Some(&cbor::Value::Array(vec![ + cbor::Value::Integer(0), + cbor::Value::Integer(1), + ])), ); } } From 72ec32167cc05fa0f8c963a98d1170ffac8eb128 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 20:13:42 +0100 Subject: [PATCH 07/17] docs(cable): note CTAP 2.3 hybrid BLE data channel support Adds a README feature line for the direct BLE L2CAP data channel, and updates the cable example wording since the channel is no longer always a tunnel. The example exercises CTAP 2.3 hybrid BLE unchanged: the QR code advertises BLE and connection_stage selects L2CAP when offered. --- README.md | 1 + libwebauthn/examples/ceremony/webauthn_cable.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c10f4c64..6681462b 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 diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 09a44678..7d4b6b8e 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -86,7 +86,7 @@ pub async fn main() -> Result<(), Box> { println!("{}", image); let mut channel = device.channel().await.unwrap(); - println!("Tunnel established {:?}", channel); + println!("Channel established {:?}", channel); let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); @@ -118,7 +118,7 @@ pub async fn main() -> Result<(), Box> { .unwrap(); let mut channel = known_device.channel().await.unwrap(); - println!("Tunnel established {:?}", channel); + println!("Channel established {:?}", channel); let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); From a6f6141f27a9687a9c5eea843066623f0fbc55a4 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 08:55:49 +0100 Subject: [PATCH 08/17] fix(cable): use bluer Stream::connect for L2CAP, drop set_security The kernel rejects BT_SECURITY level < LOW on L2CAP CoC sockets with -EINVAL (l2cap_sock_setsockopt), so set_security(Sdp) was failing the whole connect path. The default sec_level on a fresh socket is already BT_SECURITY_LOW, which on an LE link does not trigger pairing or encryption: the 'insecure' CoC the spec asks for. Switching to bluer::l2cap::Stream::connect also drops the redundant explicit bind, which it does internally. --- libwebauthn/src/transport/cable/l2cap.rs | 43 ++++++++---------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/libwebauthn/src/transport/cable/l2cap.rs b/libwebauthn/src/transport/cable/l2cap.rs index 20f0f802..b2940322 100644 --- a/libwebauthn/src/transport/cable/l2cap.rs +++ b/libwebauthn/src/transport/cable/l2cap.rs @@ -21,8 +21,13 @@ pub(crate) struct L2capDataChannel { } impl L2capDataChannel { - /// Connects to the peer's auto-generated PSM over an insecure - /// ([`SecurityLevel::Sdp`]) L2CAP CoC, taking the peer's btleplug address. + /// 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, @@ -30,33 +35,13 @@ impl L2capDataChannel { ) -> Result { let (addr, addr_type) = bdaddr_to_bluer(addr, addr_type)?; - let socket = bluer::l2cap::Socket::::new_stream().map_err(|e| { - error!(?e, "Failed to create L2CAP stream socket"); - TransportError::IoError(e.kind()) - })?; - socket - .bind(bluer::l2cap::SocketAddr::any_le()) - .map_err(|e| { - error!(?e, "Failed to bind L2CAP socket"); - TransportError::IoError(e.kind()) - })?; - // Insecure CoC: security must be set before connect(). - socket - .set_security(bluer::l2cap::Security { - level: bluer::l2cap::SecurityLevel::Sdp, - key_size: 0, - }) - .map_err(|e| { - error!(?e, "Failed to set L2CAP security level"); - TransportError::IoError(e.kind()) - })?; - let stream = socket - .connect(bluer::l2cap::SocketAddr::new(addr, addr_type, psm)) - .await - .map_err(|e| { - error!(?e, %addr, psm, "Failed to connect L2CAP CoC"); - TransportError::ConnectionFailed - })?; + 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 + })?; Ok(Self { stream, From 389446e10105b57e3125234caeb4480bb96247db Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 08:56:32 +0100 Subject: [PATCH 09/17] fix(cable): wait for negotiated L2CAP send MTU before first write bluer's poll_write silently caps writes to 16 bytes while BT_SNDMTU is not yet available, which happens for a brief window after connect() returns and before the LE Credit Based Connection Response is processed by the kernel. The ~80-byte Noise handshake then goes out as 5+ SDUs, which some peers don't reassemble before timing out. Poll send_mtu() (up to 2s, 50ms cadence) until the kernel has the peer's MTU, so the handshake is sent in a single SDU. --- libwebauthn/src/transport/cable/l2cap.rs | 38 ++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/libwebauthn/src/transport/cable/l2cap.rs b/libwebauthn/src/transport/cable/l2cap.rs index b2940322..0dcc17cd 100644 --- a/libwebauthn/src/transport/cable/l2cap.rs +++ b/libwebauthn/src/transport/cable/l2cap.rs @@ -1,10 +1,12 @@ //! [`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 tracing::{error, warn}; +use tokio::time::Instant; +use tracing::{debug, error, warn}; use super::data_channel::CableDataChannel; use crate::transport::error::TransportError; @@ -12,6 +14,12 @@ 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 { @@ -43,6 +51,8 @@ impl L2capDataChannel { TransportError::ConnectionFailed })?; + await_send_mtu(&stream).await; + Ok(Self { stream, read_buf: Vec::new(), @@ -50,11 +60,33 @@ impl L2capDataChannel { } } +/// 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> { - // NOTE: CRLF-terminating binary ciphertext is ambiguous in the CTAP 2.3 hybrid draft - // and may need revisiting against real hardware. self.stream.write_all(message).await.map_err(|e| { error!(?e, "Failed to write L2CAP message"); TransportError::IoError(e.kind()) From 4be7fe74f8b928d4c30d55f40d437f296712906d Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 09:27:27 +0100 Subject: [PATCH 10/17] fix(cable): omit QR key 6 by default to keep legacy caBLE clients happy In caBLE v2 key 6 was a 'supports_non_discoverable_mc' boolean. The PXP draft repurposes it as the supported-transports array. Legacy clients (Google Play services Fido 26.18.x, observed via 'HybridAuthenticate- ChimeraActivity ... Expected a jcsv value, but got jcsw' on QR parse) hard-reject a CBOR array where they expect a bool, and crash the scan. Make the field Option> and default to None; CTAP 2.3 hybrid-aware peers that need the new array semantics can opt in via the public field once they ship. --- .../src/transport/cable/qr_code_device.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index 4226d831..76d0fd35 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -79,8 +79,13 @@ pub struct CableQrCode { pub operation_hint: QrCodeOperationHint, /// Key 6: data transfer channels the client supports (0 = WebSocket, 1 = BLE). + /// Omitted by default: caBLE v2 used key 6 as a `supports_non_discoverable_mc` + /// boolean, and legacy clients (e.g. Google Play services Fido) hard-reject a + /// CBOR array there with a type mismatch on QR parse. The CTAP 2.3 hybrid-aware path + /// requires opting in once peers ship support. + #[serde(skip_serializing_if = "Option::is_none")] #[serde(index = 0x06)] - pub transports: Vec, + pub transports: Option>, } impl std::fmt::Display for CableQrCode { @@ -151,7 +156,7 @@ impl CableQrCodeDevice { current_time: current_unix_time, operation_hint: hint, state_assisted: Some(state_assisted), - transports: vec![CableTransportChannel::WebSocket, CableTransportChannel::Ble], + transports: None, }, private_key: private_key_scalar, store, @@ -260,10 +265,23 @@ mod tests { use std::collections::BTreeMap; #[test] - fn qr_code_encodes_transport_channels_at_key_6() { + fn qr_code_omits_key_6_by_default() { let device = CableQrCodeDevice::new_transient(QrCodeOperationHint::MakeCredential).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_transport_channels_at_key_6_when_set() { + let mut device = + CableQrCodeDevice::new_transient(QrCodeOperationHint::MakeCredential).unwrap(); + device.qr_code.transports = Some(vec![ + CableTransportChannel::WebSocket, + CableTransportChannel::Ble, + ]); + 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![ From cd2369bed4e5d3febf6d624aa6ce87cef6c29358 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 09:33:04 +0100 Subject: [PATCH 11/17] fix(cable): omit QR key 4 when state_assisted is false Per the caBLE v2 convention (cited by kanidm): Chrome omits this field when false; presence implies v2.1, absence implies v2.0. Emitting 'state_assisted = false' was claiming v2.1 with no state-assisted support, which is an unusual combination and the next plausible trigger for legacy GMS Fido's strict QR parser to reject. --- libwebauthn/src/transport/cable/qr_code_device.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index 76d0fd35..f597fc17 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -155,7 +155,9 @@ 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), + // Chrome convention: omit key 4 when false (presence implies + // caBLE v2.1, absence implies v2.0). + state_assisted: state_assisted.then_some(true), transports: None, }, private_key: private_key_scalar, From df8842ee81d5449eb1a0a0ee6a9b6ab843f6d3a7 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 10:58:36 +0100 Subject: [PATCH 12/17] feat(cable): require an explicit CableTransports in QR constructors CableQrCodeDevice::new_persistent and new_transient now take a CableTransports describing which transport channels the QR advertises: websocket_only (caBLE v2 legacy), ble_only, or websocket_and_ble. The set is a thin BTreeSet wrapper. Internally we collapse websocket_only to no key 6, since legacy parsers hard-reject a CBOR array at key 6 (where caBLE v2 expected a supports_non_discoverable_mc boolean). Anything that includes BLE opts in to the CTAP 2.3 hybrid transport-channel negotiation. --- .../examples/ceremony/webauthn_cable.rs | 5 +- .../src/transport/cable/qr_code_device.rs | 83 ++++++++++++++----- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 7d4b6b8e..47914b80 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -6,7 +6,9 @@ use libwebauthn::transport::cable::is_available; use libwebauthn::transport::cable::known_devices::{ CableKnownDevice, ClientPayloadHint, EphemeralDeviceInfoStore, }; -use libwebauthn::transport::cable::qr_code_device::{CableQrCodeDevice, QrCodeOperationHint}; +use libwebauthn::transport::cable::qr_code_device::{ + CableQrCodeDevice, CableTransports, QrCodeOperationHint, +}; use qrcode::render::unicode; use qrcode::QrCode; use tokio::time::sleep; @@ -74,6 +76,7 @@ pub async fn main() -> Result<(), Box> { let mut device: CableQrCodeDevice = CableQrCodeDevice::new_persistent( QrCodeOperationHint::MakeCredential, device_info_store.clone(), + CableTransports::CloudAssistedOnly, )?; println!("Created QR code, awaiting for advertisement."); diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index f597fc17..898005f6 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -37,14 +37,44 @@ pub enum QrCodeOperationHint { MakeCredential, } -/// A data transfer channel the client supports, as listed in QR code key 6. -#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr)] +/// One of the data transfer channels listed in QR code key 6. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr)] #[repr(u8)] -pub enum CableTransportChannel { +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. @@ -78,14 +108,13 @@ pub struct CableQrCode { #[serde(index = 0x05)] pub operation_hint: QrCodeOperationHint, - /// Key 6: data transfer channels the client supports (0 = WebSocket, 1 = BLE). - /// Omitted by default: caBLE v2 used key 6 as a `supports_non_discoverable_mc` - /// boolean, and legacy clients (e.g. Google Play services Fido) hard-reject a - /// CBOR array there with a type mismatch on QR parse. The CTAP 2.3 hybrid-aware path - /// requires opting in once peers ship support. + /// 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 transports: Option>, + pub(crate) transports: Option>, } impl std::fmt::Display for CableQrCode { @@ -122,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); @@ -148,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), @@ -158,7 +191,7 @@ impl CableQrCodeDevice { // Chrome convention: omit key 4 when false (presence implies // caBLE v2.1, absence implies v2.0). state_assisted: state_assisted.then_some(true), - transports: None, + transports, }, private_key: private_key_scalar, store, @@ -169,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)] @@ -267,21 +303,24 @@ mod tests { use std::collections::BTreeMap; #[test] - fn qr_code_omits_key_6_by_default() { - let device = CableQrCodeDevice::new_transient(QrCodeOperationHint::MakeCredential).unwrap(); + 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_transport_channels_at_key_6_when_set() { - let mut device = - CableQrCodeDevice::new_transient(QrCodeOperationHint::MakeCredential).unwrap(); - device.qr_code.transports = Some(vec![ - CableTransportChannel::WebSocket, - CableTransportChannel::Ble, - ]); + 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!( From 9c4b96f5d306499e4eaeb8350987898018b3f8bc Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 11:04:20 +0100 Subject: [PATCH 13/17] feat(cable): fall back to a fresh QR when the peer offers no linking info The state-assisted second leg of the cable example panicked with 'No known devices found' against any peer that didn't send linking info in its post-handshake message (GMS Fido for one). Detect the empty known-devices list and show a new QR for GetAssertion instead, so the example demonstrates a working two-ceremony flow regardless of the peer's state-assisted support. --- .../examples/ceremony/webauthn_cable.rs | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 47914b80..6496aebb 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -17,6 +17,7 @@ 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; @@ -108,25 +109,53 @@ pub async fn main() -> Result<(), Box> { 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; - 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(); + 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?; + } - let mut channel = known_device.channel().await.unwrap(); - println!("Channel established {:?}", channel); + 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) + 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 { @@ -135,6 +164,5 @@ pub async fn main() -> Result<(), Box> { .expect("Failed to serialize GetAssertion response"); println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}"); } - Ok(()) } From ba7b618d868022d99529a49649f00079b2779219 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 11:11:25 +0100 Subject: [PATCH 14/17] refactor(cable): rename webauthn_cable example to webauthn_cable_wss The 'cable' name is reserved for an upcoming example that exercises both transports. --- README.md | 2 +- libwebauthn/Cargo.toml | 4 ++-- .../ceremony/{webauthn_cable.rs => webauthn_cable_wss.rs} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename libwebauthn/examples/ceremony/{webauthn_cable.rs => webauthn_cable_wss.rs} (100%) diff --git a/README.md b/README.md index 6681462b..6f458a6b 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ 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)** | — | `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 41b6d091..4b3931f9 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -123,8 +123,8 @@ path = "examples/ceremony/webauthn_nfc.rs" required-features = ["nfc"] [[example]] -name = "webauthn_cable" -path = "examples/ceremony/webauthn_cable.rs" +name = "webauthn_cable_wss" +path = "examples/ceremony/webauthn_cable_wss.rs" [[example]] name = "webauthn_extensions_hid" diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs similarity index 100% rename from libwebauthn/examples/ceremony/webauthn_cable.rs rename to libwebauthn/examples/ceremony/webauthn_cable_wss.rs From fd614e208d0f04eb130768b3904cec8439da71e4 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 11:38:13 +0100 Subject: [PATCH 15/17] feat(cable): add webauthn_cable example exercising both transports Transient MakeCredential only, CableTransports::CloudAssistedOrLocal. The QR offers both the WebSocket tunnel and the BLE L2CAP channel; the authenticator picks one (CTAP 2.3-aware peers may open L2CAP, caBLE v2 peers silently ignore key 6 and fall back to the WebSocket tunnel). --- README.md | 1 + libwebauthn/Cargo.toml | 4 + .../examples/ceremony/webauthn_cable.rs | 92 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 libwebauthn/examples/ceremony/webauthn_cable.rs diff --git a/README.md b/README.md index 6f458a6b..b237985c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ 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 + 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 4b3931f9..633f1ce7 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -122,6 +122,10 @@ name = "webauthn_nfc" path = "examples/ceremony/webauthn_nfc.rs" required-features = ["nfc"] +[[example]] +name = "webauthn_cable" +path = "examples/ceremony/webauthn_cable.rs" + [[example]] name = "webauthn_cable_wss" path = "examples/ceremony/webauthn_cable_wss.rs" diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs new file mode 100644 index 00000000..779406b4 --- /dev/null +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -0,0 +1,92 @@ +//! 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 libwebauthn::transport::cable::is_available; +use libwebauthn::transport::cable::qr_code_device::{ + CableQrCodeDevice, CableTransports, QrCodeOperationHint, +}; +use qrcode::render::unicode; +use qrcode::QrCode; + +use libwebauthn::ops::webauthn::{ + DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin, WebAuthnIDL as _, + WebAuthnIDLResponse as _, +}; +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" +} +"#; + +#[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 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_transient( + QrCodeOperationHint::MakeCredential, + CableTransports::CloudAssistedOrLocal, + )?; + + 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}"); + + Ok(()) +} From 0c42f4e4b9831d28f9987f6efc6532188f744a5c Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 17:39:38 +0100 Subject: [PATCH 16/17] fix(cable): treat ECONNRESET / EPIPE on L2CAP read as a clean close After a successful CTAP ceremony the peer typically tears down the BLE link without a clean L2CAP shutdown, so our read loop wakes up with ECONNRESET (or EPIPE / UnexpectedEof) rather than n=0. Treat those the same way we already treat a clean EOF: return Ok(None) when nothing is buffered, ConnectionLost when half a message is. Stops the spurious 'Failed to read L2CAP message: ConnectionReset' error log that fires after a successful MakeCredential. --- libwebauthn/src/transport/cable/l2cap.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/libwebauthn/src/transport/cable/l2cap.rs b/libwebauthn/src/transport/cable/l2cap.rs index 0dcc17cd..ad197921 100644 --- a/libwebauthn/src/transport/cable/l2cap.rs +++ b/libwebauthn/src/transport/cable/l2cap.rs @@ -104,10 +104,26 @@ impl CableDataChannel for L2capDataChannel { return Ok(Some(message)); } let mut chunk = [0u8; 1024]; - let n = self.stream.read(&mut chunk).await.map_err(|e| { - error!(?e, "Failed to read L2CAP message"); - TransportError::IoError(e.kind()) - })?; + 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() { From 78214b6c0253c92887bc96b8d19aec8491c8ca62 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 15 May 2026 18:04:05 +0100 Subject: [PATCH 17/17] chore(cable): drop dead unsafe Send/Sync, fix top-of-README table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CableQrCodeDevice's fields (NonZeroScalar, ByteArray, Option>) all auto-derive Send + Sync, so the hand-rolled unsafe impls are dead. Verified compile-time via a T: Send + Sync assertion in the test module; downstream consumers (credentialsd spawns CableQrCodeDevice across tokio::spawn) keep working. Also brings the top-of-README transport-support table in line with the example table further down — caBLE v2 plus CTAP 2.3 hybrid. --- README.md | 2 +- libwebauthn/src/transport/cable/qr_code_device.rs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b237985c..7c9e42ef 100644 --- a/README.md +++ b/README.md @@ -51,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 diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index 898005f6..e4b52676 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -231,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") @@ -302,6 +298,13 @@ 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(