From 9ef8e960f53c3793e666bac1b6a2b91d0188dce8 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 7 Jun 2026 21:40:18 +0100 Subject: [PATCH 1/2] fix(cable): follow tunnel redirects and forget gone known devices connect() now follows HTTP 3xx redirects to the Location target with a redirect cap, re-attaching the fido.cable and client-payload headers on each hop. A 410 Gone surfaces as a distinct error, and a known-device contact connection forgets the linking record on 410 so it is not retried forever. --- .../src/transport/cable/connection_stages.rs | 22 +- libwebauthn/src/transport/cable/tunnel.rs | 262 +++++++++++++++--- libwebauthn/src/transport/error.rs | 3 + 3 files changed, 251 insertions(+), 36 deletions(-) diff --git a/libwebauthn/src/transport/cable/connection_stages.rs b/libwebauthn/src/transport/cable/connection_stages.rs index 24ed19f7..7f3cd9f8 100644 --- a/libwebauthn/src/transport/cable/connection_stages.rs +++ b/libwebauthn/src/transport/cable/connection_stages.rs @@ -66,6 +66,8 @@ pub(crate) struct ConnectionInput { pub connection_type: CableTunnelConnectionType, /// Some if the CMHD offered a BLE L2CAP channel; None selects WebSocket. pub ble: Option, + /// Present for known-device connections, so a 410 Gone can forget the record. + pub known_device_store: Option>, } impl ConnectionInput { @@ -107,6 +109,7 @@ impl ConnectionInput { tunnel_domain, connection_type, ble, + known_device_store: None, }) } @@ -133,6 +136,7 @@ impl ConnectionInput { tunnel_domain: known_device.device_info.tunnel_domain.clone(), connection_type, ble: None, + known_device_store: Some(known_device.store.clone()), } } } @@ -323,7 +327,23 @@ async fn connect_data_channel( } } - let ws_stream = tunnel::connect(&input.tunnel_domain, &input.connection_type).await?; + let ws_stream = match tunnel::connect(&input.tunnel_domain, &input.connection_type).await { + Ok(ws_stream) => ws_stream, + Err(error) => { + if let Some(device_id) = + tunnel::known_device_id_to_forget(&error, &input.connection_type) + { + if let Some(store) = &input.known_device_store { + warn!( + ?device_id, + "Tunnel server returned 410 Gone; forgetting known device" + ); + store.delete_known_device(&device_id).await; + } + } + return Err(error); + } + }; info!(tunnel_domain = %input.tunnel_domain, "Connected over WebSocket tunnel"); Ok(Box::new(WebSocketDataChannel::new(ws_stream))) } diff --git a/libwebauthn/src/transport/cable/tunnel.rs b/libwebauthn/src/transport/cable/tunnel.rs index d1e2c7ab..f116cfd0 100644 --- a/libwebauthn/src/transport/cable/tunnel.rs +++ b/libwebauthn/src/transport/cable/tunnel.rs @@ -1,15 +1,21 @@ //! WebSocket tunnel-server transport for the caBLE hybrid protocol. use sha2::{Digest, Sha256}; use tokio::net::TcpStream; -use tokio_tungstenite::tungstenite::http::StatusCode; +use tokio_tungstenite::tungstenite::handshake::client::Request; +use tokio_tungstenite::tungstenite::http::{header::LOCATION, StatusCode}; +use tokio_tungstenite::tungstenite::Error as TungsteniteError; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use tracing::{debug, error, trace}; use tungstenite::client::IntoClientRequest; +use url::Url; +use super::known_devices::CableKnownDeviceId; use super::protocol::CableTunnelConnectionType; use crate::proto::ctap2::cbor; use crate::transport::error::TransportError; +const MAX_TUNNEL_REDIRECTS: usize = 5; + fn ensure_rustls_crypto_provider() { use std::sync::Once; static RUSTLS_INIT: Once = Once::new(); @@ -55,13 +61,77 @@ pub fn decode_tunnel_server_domain(encoded: u16) -> Option { Some(ret) } +/// Builds the tunnel request, re-attaching the fido.cable and client-payload headers. +pub(crate) fn build_tunnel_request( + url: &str, + connection_type: &CableTunnelConnectionType, +) -> Result { + let mut request = url + .into_client_request() + .or(Err(TransportError::InvalidEndpoint))?; + let headers = request.headers_mut(); + headers.insert( + "Sec-WebSocket-Protocol", + "fido.cable" + .parse() + .or(Err(TransportError::InvalidEndpoint))?, + ); + + if let CableTunnelConnectionType::KnownDevice { client_payload, .. } = connection_type { + let client_payload = + cbor::to_vec(client_payload).or(Err(TransportError::InvalidEndpoint))?; + headers.insert( + "X-caBLE-Client-Payload", + hex::encode(client_payload) + .parse() + .or(Err(TransportError::InvalidEndpoint))?, + ); + } + Ok(request) +} + +/// Resolves a redirect Location, which may be relative, against the current URL. +fn resolve_redirect_target(base: &str, location: &str) -> Result { + let base = Url::parse(base).or(Err(TransportError::InvalidEndpoint))?; + let target = base + .join(location) + .or(Err(TransportError::InvalidEndpoint))?; + Ok(target.to_string()) +} + +/// Maps a non-101 tunnel handshake status to a transport error, distinguishing 410 Gone. +fn tunnel_status_error(status: StatusCode) -> TransportError { + if status == StatusCode::GONE { + TransportError::TunnelServerGone + } else { + TransportError::ConnectionFailed + } +} + +/// The known-device id to forget on a 410 Gone, for a known-device connection. +pub(crate) fn known_device_id_to_forget( + error: &TransportError, + connection_type: &CableTunnelConnectionType, +) -> Option { + match (error, connection_type) { + ( + TransportError::TunnelServerGone, + CableTunnelConnectionType::KnownDevice { + authenticator_public_key, + .. + }, + ) => Some(hex::encode(authenticator_public_key)), + _ => None, + } +} + pub(crate) async fn connect( tunnel_domain: &str, connection_type: &CableTunnelConnectionType, ) -> Result>, TransportError> { ensure_rustls_crypto_provider(); - let connect_url = match connection_type { + let mut connect_url = match connection_type { CableTunnelConnectionType::QrCode { routing_id, tunnel_id, @@ -74,50 +144,81 @@ pub(crate) async fn connect( format!("wss://{}/cable/contact/{}", tunnel_domain, contact_id) } }; - debug!(?connect_url, "Connecting to tunnel server"); - let mut request = connect_url - .into_client_request() - .or(Err(TransportError::InvalidEndpoint))?; - request.headers_mut().insert( - "Sec-WebSocket-Protocol", - "fido.cable" - .parse() - .or(Err(TransportError::InvalidEndpoint))?, - ); - if let CableTunnelConnectionType::KnownDevice { client_payload, .. } = connection_type { - let client_payload = - cbor::to_vec(client_payload).or(Err(TransportError::InvalidEndpoint))?; - request.headers_mut().insert( - "X-caBLE-Client-Payload", - hex::encode(client_payload) - .parse() - .or(Err(TransportError::InvalidEndpoint))?, - ); - } - trace!(?request); + for _ in 0..=MAX_TUNNEL_REDIRECTS { + debug!(?connect_url, "Connecting to tunnel server"); + let request = build_tunnel_request(&connect_url, connection_type)?; + trace!(?request); + + let error = match connect_async(request).await { + Ok((ws_stream, response)) => { + debug!(?response, "Connected to tunnel server"); + if response.status() != StatusCode::SWITCHING_PROTOCOLS { + error!(?response, "Failed to switch to websocket protocol"); + return Err(TransportError::ConnectionFailed); + } + debug!("Tunnel server returned success"); + return Ok(ws_stream); + } + Err(error) => error, + }; - let (ws_stream, response) = match connect_async(request).await { - Ok((ws_stream, response)) => (ws_stream, response), - Err(e) => { - error!(?e, "Failed to connect to tunnel server"); + let TungsteniteError::Http(response) = error else { + error!(?error, "Failed to connect to tunnel server"); return Err(TransportError::ConnectionFailed); + }; + + let status = response.status(); + if status.is_redirection() { + let Some(location) = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + else { + error!(?status, "Tunnel redirect missing a usable Location header"); + return Err(TransportError::ConnectionFailed); + }; + connect_url = resolve_redirect_target(&connect_url, location)?; + debug!(?connect_url, "Following tunnel redirect"); + continue; } - }; - debug!(?response, "Connected to tunnel server"); - if response.status() != StatusCode::SWITCHING_PROTOCOLS { - error!(?response, "Failed to switch to websocket protocol"); - return Err(TransportError::ConnectionFailed); + error!(?status, "Tunnel server rejected the connection"); + return Err(tunnel_status_error(status)); } - debug!("Tunnel server returned success"); - Ok(ws_stream) + error!("Exceeded the maximum number of tunnel redirects"); + Err(TransportError::ConnectionFailed) } #[cfg(test)] mod tests { use super::*; + use crate::transport::cable::known_devices::{ClientPayload, ClientPayloadHint}; + use p256::NonZeroScalar; + use rand::rngs::OsRng; + use serde_bytes::ByteBuf; + + fn known_device_connection_type(public_key: Vec) -> CableTunnelConnectionType { + CableTunnelConnectionType::KnownDevice { + contact_id: "contact-id".to_string(), + authenticator_public_key: public_key, + client_payload: ClientPayload { + link_id: ByteBuf::from(vec![1u8; 8]), + client_nonce: ByteBuf::from(vec![2u8; 16]), + hint: ClientPayloadHint::GetAssertion, + }, + } + } + + fn qr_connection_type() -> CableTunnelConnectionType { + CableTunnelConnectionType::QrCode { + routing_id: "aabbcc".to_string(), + tunnel_id: "00112233445566778899aabbccddeeff".to_string(), + private_key: NonZeroScalar::random(&mut OsRng), + } + } + #[test] fn decode_tunnel_server_domain_known() { assert_eq!( @@ -130,5 +231,96 @@ mod tests { ); } - // TODO: test the non-known case + #[test] + fn resolve_redirect_target_relative_and_absolute() { + let base = "wss://cable.example.com/cable/contact/abc"; + assert_eq!( + resolve_redirect_target(base, "/cable/contact/v2/abc").unwrap(), + "wss://cable.example.com/cable/contact/v2/abc" + ); + assert_eq!( + resolve_redirect_target(base, "wss://cable.example.net/cable/contact/xyz").unwrap(), + "wss://cable.example.net/cable/contact/xyz" + ); + } + + #[test] + fn build_tunnel_request_reattaches_headers_for_known_device() { + let connection_type = known_device_connection_type(vec![4u8; 65]); + let request = build_tunnel_request( + "wss://cable.example.com/cable/contact/abc", + &connection_type, + ) + .unwrap(); + assert_eq!( + request + .headers() + .get("Sec-WebSocket-Protocol") + .unwrap() + .to_str() + .unwrap(), + "fido.cable" + ); + assert!(request.headers().get("X-caBLE-Client-Payload").is_some()); + } + + #[test] + fn build_tunnel_request_omits_payload_for_qr_code() { + let connection_type = qr_connection_type(); + let request = build_tunnel_request( + "wss://cable.example.com/cable/connect/aabbcc/0011", + &connection_type, + ) + .unwrap(); + assert_eq!( + request + .headers() + .get("Sec-WebSocket-Protocol") + .unwrap() + .to_str() + .unwrap(), + "fido.cable" + ); + assert!(request.headers().get("X-caBLE-Client-Payload").is_none()); + } + + #[test] + fn gone_forgets_known_device() { + let public_key = vec![7u8; 65]; + let connection_type = known_device_connection_type(public_key.clone()); + assert_eq!( + known_device_id_to_forget(&TransportError::TunnelServerGone, &connection_type), + Some(hex::encode(&public_key)) + ); + } + + #[test] + fn gone_does_not_forget_qr_code() { + let connection_type = qr_connection_type(); + assert_eq!( + known_device_id_to_forget(&TransportError::TunnelServerGone, &connection_type), + None + ); + } + + #[test] + fn non_gone_error_does_not_forget_known_device() { + let connection_type = known_device_connection_type(vec![7u8; 65]); + assert_eq!( + known_device_id_to_forget(&TransportError::ConnectionFailed, &connection_type), + None + ); + } + + #[test] + fn gone_status_maps_to_distinct_error() { + assert_eq!( + tunnel_status_error(StatusCode::GONE), + TransportError::TunnelServerGone + ); + assert_eq!( + tunnel_status_error(StatusCode::BAD_GATEWAY), + TransportError::ConnectionFailed + ); + } } diff --git a/libwebauthn/src/transport/error.rs b/libwebauthn/src/transport/error.rs index 0d1f2c08..3b2342ff 100644 --- a/libwebauthn/src/transport/error.rs +++ b/libwebauthn/src/transport/error.rs @@ -4,6 +4,9 @@ pub enum TransportError { ConnectionFailed, #[error("connection lost")] ConnectionLost, + /// The tunnel server returned HTTP 410 Gone for the contacted resource. + #[error("tunnel server reported the resource is gone")] + TunnelServerGone, #[error("invalid endpoint")] InvalidEndpoint, #[error("invalid framing")] From 6b40b4fd39c39529ef702fd08f8e846c050467aa Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 15 Jun 2026 23:29:00 +0100 Subject: [PATCH 2/2] refactor(cable): namespace tunnel errors under CableTunnelError --- libwebauthn/src/transport/cable/error.rs | 14 ++++++++++++++ libwebauthn/src/transport/cable/mod.rs | 1 + libwebauthn/src/transport/cable/tunnel.rs | 23 +++++++++++++++-------- libwebauthn/src/transport/error.rs | 8 +++++--- 4 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 libwebauthn/src/transport/cable/error.rs diff --git a/libwebauthn/src/transport/cable/error.rs b/libwebauthn/src/transport/cable/error.rs new file mode 100644 index 00000000..66aaa541 --- /dev/null +++ b/libwebauthn/src/transport/cable/error.rs @@ -0,0 +1,14 @@ +//! Errors specific to the caBLE tunnel-server transport. + +#[derive(thiserror::Error, Debug, PartialEq, Clone)] +pub enum CableTunnelError { + /// The tunnel server returned HTTP 410 Gone for the contacted resource. + #[error("tunnel server reported the resource is gone (HTTP 410)")] + Gone, + /// The tunnel server returned an unexpected, non-success HTTP status. + #[error("tunnel server returned unexpected HTTP status {0}")] + UnexpectedStatus(u16), + /// The tunnel server kept redirecting past the allowed limit. + #[error("tunnel server exceeded the maximum number of redirects")] + TooManyRedirects, +} diff --git a/libwebauthn/src/transport/cable/mod.rs b/libwebauthn/src/transport/cable/mod.rs index d579facc..5258344e 100644 --- a/libwebauthn/src/transport/cable/mod.rs +++ b/libwebauthn/src/transport/cable/mod.rs @@ -9,6 +9,7 @@ mod protocol; pub mod advertisement; pub mod channel; pub mod connection_stages; +pub mod error; pub mod known_devices; pub mod qr_code_device; pub mod tunnel; diff --git a/libwebauthn/src/transport/cable/tunnel.rs b/libwebauthn/src/transport/cable/tunnel.rs index f116cfd0..1f710ac2 100644 --- a/libwebauthn/src/transport/cable/tunnel.rs +++ b/libwebauthn/src/transport/cable/tunnel.rs @@ -9,6 +9,7 @@ use tracing::{debug, error, trace}; use tungstenite::client::IntoClientRequest; use url::Url; +use super::error::CableTunnelError; use super::known_devices::CableKnownDeviceId; use super::protocol::CableTunnelConnectionType; use crate::proto::ctap2::cbor; @@ -102,9 +103,9 @@ fn resolve_redirect_target(base: &str, location: &str) -> Result TransportError { if status == StatusCode::GONE { - TransportError::TunnelServerGone + CableTunnelError::Gone.into() } else { - TransportError::ConnectionFailed + CableTunnelError::UnexpectedStatus(status.as_u16()).into() } } @@ -115,7 +116,7 @@ pub(crate) fn known_device_id_to_forget( ) -> Option { match (error, connection_type) { ( - TransportError::TunnelServerGone, + TransportError::CableTunnel(CableTunnelError::Gone), CableTunnelConnectionType::KnownDevice { authenticator_public_key, .. @@ -188,7 +189,7 @@ pub(crate) async fn connect( } error!("Exceeded the maximum number of tunnel redirects"); - Err(TransportError::ConnectionFailed) + Err(CableTunnelError::TooManyRedirects.into()) } #[cfg(test)] @@ -289,7 +290,10 @@ mod tests { let public_key = vec![7u8; 65]; let connection_type = known_device_connection_type(public_key.clone()); assert_eq!( - known_device_id_to_forget(&TransportError::TunnelServerGone, &connection_type), + known_device_id_to_forget( + &TransportError::CableTunnel(CableTunnelError::Gone), + &connection_type + ), Some(hex::encode(&public_key)) ); } @@ -298,7 +302,10 @@ mod tests { fn gone_does_not_forget_qr_code() { let connection_type = qr_connection_type(); assert_eq!( - known_device_id_to_forget(&TransportError::TunnelServerGone, &connection_type), + known_device_id_to_forget( + &TransportError::CableTunnel(CableTunnelError::Gone), + &connection_type + ), None ); } @@ -316,11 +323,11 @@ mod tests { fn gone_status_maps_to_distinct_error() { assert_eq!( tunnel_status_error(StatusCode::GONE), - TransportError::TunnelServerGone + TransportError::CableTunnel(CableTunnelError::Gone) ); assert_eq!( tunnel_status_error(StatusCode::BAD_GATEWAY), - TransportError::ConnectionFailed + TransportError::CableTunnel(CableTunnelError::UnexpectedStatus(502)) ); } } diff --git a/libwebauthn/src/transport/error.rs b/libwebauthn/src/transport/error.rs index 3b2342ff..d5cdf1a0 100644 --- a/libwebauthn/src/transport/error.rs +++ b/libwebauthn/src/transport/error.rs @@ -1,12 +1,14 @@ +use crate::transport::cable::error::CableTunnelError; + #[derive(thiserror::Error, Debug, PartialEq, Clone)] pub enum TransportError { #[error("connection failed")] ConnectionFailed, #[error("connection lost")] ConnectionLost, - /// The tunnel server returned HTTP 410 Gone for the contacted resource. - #[error("tunnel server reported the resource is gone")] - TunnelServerGone, + /// An error from the caBLE tunnel-server transport. + #[error(transparent)] + CableTunnel(#[from] CableTunnelError), #[error("invalid endpoint")] InvalidEndpoint, #[error("invalid framing")]