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/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 d1e2c7ab..1f710ac2 100644 --- a/libwebauthn/src/transport/cable/tunnel.rs +++ b/libwebauthn/src/transport/cable/tunnel.rs @@ -1,15 +1,22 @@ //! 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::error::CableTunnelError; +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 +62,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 { + CableTunnelError::Gone.into() + } else { + CableTunnelError::UnexpectedStatus(status.as_u16()).into() + } +} + +/// 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::CableTunnel(CableTunnelError::Gone), + 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 +145,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(CableTunnelError::TooManyRedirects.into()) } #[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 +232,102 @@ 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::CableTunnel(CableTunnelError::Gone), + &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::CableTunnel(CableTunnelError::Gone), + &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::CableTunnel(CableTunnelError::Gone) + ); + assert_eq!( + tunnel_status_error(StatusCode::BAD_GATEWAY), + TransportError::CableTunnel(CableTunnelError::UnexpectedStatus(502)) + ); + } } diff --git a/libwebauthn/src/transport/error.rs b/libwebauthn/src/transport/error.rs index 0d1f2c08..d5cdf1a0 100644 --- a/libwebauthn/src/transport/error.rs +++ b/libwebauthn/src/transport/error.rs @@ -1,9 +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, + /// An error from the caBLE tunnel-server transport. + #[error(transparent)] + CableTunnel(#[from] CableTunnelError), #[error("invalid endpoint")] InvalidEndpoint, #[error("invalid framing")]