Skip to content

Commit c9d6b77

Browse files
fix(cable): follow tunnel redirects and forget gone links (#274)
The hybrid tunnel did not follow HTTP redirects, so a redirecting tunnel server could not be reached, and a permanently gone link kept being retried. This follows redirects with the required headers reattached, and treats a gone response as permanent by forgetting the stored link. Part of #257.
1 parent d593d6b commit c9d6b77

5 files changed

Lines changed: 275 additions & 36 deletions

File tree

libwebauthn/src/transport/cable/connection_stages.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ pub(crate) struct ConnectionInput {
6666
pub connection_type: CableTunnelConnectionType,
6767
/// Some if the CMHD offered a BLE L2CAP channel; None selects WebSocket.
6868
pub ble: Option<BleConnectionParams>,
69+
/// Present for known-device connections, so a 410 Gone can forget the record.
70+
pub known_device_store: Option<Arc<dyn CableKnownDeviceInfoStore>>,
6971
}
7072

7173
impl ConnectionInput {
@@ -107,6 +109,7 @@ impl ConnectionInput {
107109
tunnel_domain,
108110
connection_type,
109111
ble,
112+
known_device_store: None,
110113
})
111114
}
112115

@@ -133,6 +136,7 @@ impl ConnectionInput {
133136
tunnel_domain: known_device.device_info.tunnel_domain.clone(),
134137
connection_type,
135138
ble: None,
139+
known_device_store: Some(known_device.store.clone()),
136140
}
137141
}
138142
}
@@ -323,7 +327,23 @@ async fn connect_data_channel(
323327
}
324328
}
325329

326-
let ws_stream = tunnel::connect(&input.tunnel_domain, &input.connection_type).await?;
330+
let ws_stream = match tunnel::connect(&input.tunnel_domain, &input.connection_type).await {
331+
Ok(ws_stream) => ws_stream,
332+
Err(error) => {
333+
if let Some(device_id) =
334+
tunnel::known_device_id_to_forget(&error, &input.connection_type)
335+
{
336+
if let Some(store) = &input.known_device_store {
337+
warn!(
338+
?device_id,
339+
"Tunnel server returned 410 Gone; forgetting known device"
340+
);
341+
store.delete_known_device(&device_id).await;
342+
}
343+
}
344+
return Err(error);
345+
}
346+
};
327347
info!(tunnel_domain = %input.tunnel_domain, "Connected over WebSocket tunnel");
328348
Ok(Box::new(WebSocketDataChannel::new(ws_stream)))
329349
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! Errors specific to the caBLE tunnel-server transport.
2+
3+
#[derive(thiserror::Error, Debug, PartialEq, Clone)]
4+
pub enum CableTunnelError {
5+
/// The tunnel server returned HTTP 410 Gone for the contacted resource.
6+
#[error("tunnel server reported the resource is gone (HTTP 410)")]
7+
Gone,
8+
/// The tunnel server returned an unexpected, non-success HTTP status.
9+
#[error("tunnel server returned unexpected HTTP status {0}")]
10+
UnexpectedStatus(u16),
11+
/// The tunnel server kept redirecting past the allowed limit.
12+
#[error("tunnel server exceeded the maximum number of redirects")]
13+
TooManyRedirects,
14+
}

libwebauthn/src/transport/cable/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod protocol;
99
pub mod advertisement;
1010
pub mod channel;
1111
pub mod connection_stages;
12+
pub mod error;
1213
pub mod known_devices;
1314
pub mod qr_code_device;
1415
pub mod tunnel;

libwebauthn/src/transport/cable/tunnel.rs

Lines changed: 234 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
//! WebSocket tunnel-server transport for the caBLE hybrid protocol.
22
use sha2::{Digest, Sha256};
33
use tokio::net::TcpStream;
4-
use tokio_tungstenite::tungstenite::http::StatusCode;
4+
use tokio_tungstenite::tungstenite::handshake::client::Request;
5+
use tokio_tungstenite::tungstenite::http::{header::LOCATION, StatusCode};
6+
use tokio_tungstenite::tungstenite::Error as TungsteniteError;
57
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
68
use tracing::{debug, error, trace};
79
use tungstenite::client::IntoClientRequest;
10+
use url::Url;
811

12+
use super::error::CableTunnelError;
13+
use super::known_devices::CableKnownDeviceId;
914
use super::protocol::CableTunnelConnectionType;
1015
use crate::proto::ctap2::cbor;
1116
use crate::transport::error::TransportError;
1217

18+
const MAX_TUNNEL_REDIRECTS: usize = 5;
19+
1320
fn ensure_rustls_crypto_provider() {
1421
use std::sync::Once;
1522
static RUSTLS_INIT: Once = Once::new();
@@ -55,13 +62,77 @@ pub fn decode_tunnel_server_domain(encoded: u16) -> Option<String> {
5562
Some(ret)
5663
}
5764

65+
/// Builds the tunnel request, re-attaching the fido.cable and client-payload headers.
66+
pub(crate) fn build_tunnel_request(
67+
url: &str,
68+
connection_type: &CableTunnelConnectionType,
69+
) -> Result<Request, TransportError> {
70+
let mut request = url
71+
.into_client_request()
72+
.or(Err(TransportError::InvalidEndpoint))?;
73+
let headers = request.headers_mut();
74+
headers.insert(
75+
"Sec-WebSocket-Protocol",
76+
"fido.cable"
77+
.parse()
78+
.or(Err(TransportError::InvalidEndpoint))?,
79+
);
80+
81+
if let CableTunnelConnectionType::KnownDevice { client_payload, .. } = connection_type {
82+
let client_payload =
83+
cbor::to_vec(client_payload).or(Err(TransportError::InvalidEndpoint))?;
84+
headers.insert(
85+
"X-caBLE-Client-Payload",
86+
hex::encode(client_payload)
87+
.parse()
88+
.or(Err(TransportError::InvalidEndpoint))?,
89+
);
90+
}
91+
Ok(request)
92+
}
93+
94+
/// Resolves a redirect Location, which may be relative, against the current URL.
95+
fn resolve_redirect_target(base: &str, location: &str) -> Result<String, TransportError> {
96+
let base = Url::parse(base).or(Err(TransportError::InvalidEndpoint))?;
97+
let target = base
98+
.join(location)
99+
.or(Err(TransportError::InvalidEndpoint))?;
100+
Ok(target.to_string())
101+
}
102+
103+
/// Maps a non-101 tunnel handshake status to a transport error, distinguishing 410 Gone.
104+
fn tunnel_status_error(status: StatusCode) -> TransportError {
105+
if status == StatusCode::GONE {
106+
CableTunnelError::Gone.into()
107+
} else {
108+
CableTunnelError::UnexpectedStatus(status.as_u16()).into()
109+
}
110+
}
111+
112+
/// The known-device id to forget on a 410 Gone, for a known-device connection.
113+
pub(crate) fn known_device_id_to_forget(
114+
error: &TransportError,
115+
connection_type: &CableTunnelConnectionType,
116+
) -> Option<CableKnownDeviceId> {
117+
match (error, connection_type) {
118+
(
119+
TransportError::CableTunnel(CableTunnelError::Gone),
120+
CableTunnelConnectionType::KnownDevice {
121+
authenticator_public_key,
122+
..
123+
},
124+
) => Some(hex::encode(authenticator_public_key)),
125+
_ => None,
126+
}
127+
}
128+
58129
pub(crate) async fn connect(
59130
tunnel_domain: &str,
60131
connection_type: &CableTunnelConnectionType,
61132
) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, TransportError> {
62133
ensure_rustls_crypto_provider();
63134

64-
let connect_url = match connection_type {
135+
let mut connect_url = match connection_type {
65136
CableTunnelConnectionType::QrCode {
66137
routing_id,
67138
tunnel_id,
@@ -74,50 +145,81 @@ pub(crate) async fn connect(
74145
format!("wss://{}/cable/contact/{}", tunnel_domain, contact_id)
75146
}
76147
};
77-
debug!(?connect_url, "Connecting to tunnel server");
78-
let mut request = connect_url
79-
.into_client_request()
80-
.or(Err(TransportError::InvalidEndpoint))?;
81-
request.headers_mut().insert(
82-
"Sec-WebSocket-Protocol",
83-
"fido.cable"
84-
.parse()
85-
.or(Err(TransportError::InvalidEndpoint))?,
86-
);
87148

88-
if let CableTunnelConnectionType::KnownDevice { client_payload, .. } = connection_type {
89-
let client_payload =
90-
cbor::to_vec(client_payload).or(Err(TransportError::InvalidEndpoint))?;
91-
request.headers_mut().insert(
92-
"X-caBLE-Client-Payload",
93-
hex::encode(client_payload)
94-
.parse()
95-
.or(Err(TransportError::InvalidEndpoint))?,
96-
);
97-
}
98-
trace!(?request);
149+
for _ in 0..=MAX_TUNNEL_REDIRECTS {
150+
debug!(?connect_url, "Connecting to tunnel server");
151+
let request = build_tunnel_request(&connect_url, connection_type)?;
152+
trace!(?request);
153+
154+
let error = match connect_async(request).await {
155+
Ok((ws_stream, response)) => {
156+
debug!(?response, "Connected to tunnel server");
157+
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
158+
error!(?response, "Failed to switch to websocket protocol");
159+
return Err(TransportError::ConnectionFailed);
160+
}
161+
debug!("Tunnel server returned success");
162+
return Ok(ws_stream);
163+
}
164+
Err(error) => error,
165+
};
99166

100-
let (ws_stream, response) = match connect_async(request).await {
101-
Ok((ws_stream, response)) => (ws_stream, response),
102-
Err(e) => {
103-
error!(?e, "Failed to connect to tunnel server");
167+
let TungsteniteError::Http(response) = error else {
168+
error!(?error, "Failed to connect to tunnel server");
104169
return Err(TransportError::ConnectionFailed);
170+
};
171+
172+
let status = response.status();
173+
if status.is_redirection() {
174+
let Some(location) = response
175+
.headers()
176+
.get(LOCATION)
177+
.and_then(|value| value.to_str().ok())
178+
else {
179+
error!(?status, "Tunnel redirect missing a usable Location header");
180+
return Err(TransportError::ConnectionFailed);
181+
};
182+
connect_url = resolve_redirect_target(&connect_url, location)?;
183+
debug!(?connect_url, "Following tunnel redirect");
184+
continue;
105185
}
106-
};
107-
debug!(?response, "Connected to tunnel server");
108186

109-
if response.status() != StatusCode::SWITCHING_PROTOCOLS {
110-
error!(?response, "Failed to switch to websocket protocol");
111-
return Err(TransportError::ConnectionFailed);
187+
error!(?status, "Tunnel server rejected the connection");
188+
return Err(tunnel_status_error(status));
112189
}
113-
debug!("Tunnel server returned success");
114190

115-
Ok(ws_stream)
191+
error!("Exceeded the maximum number of tunnel redirects");
192+
Err(CableTunnelError::TooManyRedirects.into())
116193
}
117194

118195
#[cfg(test)]
119196
mod tests {
120197
use super::*;
198+
use crate::transport::cable::known_devices::{ClientPayload, ClientPayloadHint};
199+
use p256::NonZeroScalar;
200+
use rand::rngs::OsRng;
201+
use serde_bytes::ByteBuf;
202+
203+
fn known_device_connection_type(public_key: Vec<u8>) -> CableTunnelConnectionType {
204+
CableTunnelConnectionType::KnownDevice {
205+
contact_id: "contact-id".to_string(),
206+
authenticator_public_key: public_key,
207+
client_payload: ClientPayload {
208+
link_id: ByteBuf::from(vec![1u8; 8]),
209+
client_nonce: ByteBuf::from(vec![2u8; 16]),
210+
hint: ClientPayloadHint::GetAssertion,
211+
},
212+
}
213+
}
214+
215+
fn qr_connection_type() -> CableTunnelConnectionType {
216+
CableTunnelConnectionType::QrCode {
217+
routing_id: "aabbcc".to_string(),
218+
tunnel_id: "00112233445566778899aabbccddeeff".to_string(),
219+
private_key: NonZeroScalar::random(&mut OsRng),
220+
}
221+
}
222+
121223
#[test]
122224
fn decode_tunnel_server_domain_known() {
123225
assert_eq!(
@@ -130,5 +232,102 @@ mod tests {
130232
);
131233
}
132234

133-
// TODO: test the non-known case
235+
#[test]
236+
fn resolve_redirect_target_relative_and_absolute() {
237+
let base = "wss://cable.example.com/cable/contact/abc";
238+
assert_eq!(
239+
resolve_redirect_target(base, "/cable/contact/v2/abc").unwrap(),
240+
"wss://cable.example.com/cable/contact/v2/abc"
241+
);
242+
assert_eq!(
243+
resolve_redirect_target(base, "wss://cable.example.net/cable/contact/xyz").unwrap(),
244+
"wss://cable.example.net/cable/contact/xyz"
245+
);
246+
}
247+
248+
#[test]
249+
fn build_tunnel_request_reattaches_headers_for_known_device() {
250+
let connection_type = known_device_connection_type(vec![4u8; 65]);
251+
let request = build_tunnel_request(
252+
"wss://cable.example.com/cable/contact/abc",
253+
&connection_type,
254+
)
255+
.unwrap();
256+
assert_eq!(
257+
request
258+
.headers()
259+
.get("Sec-WebSocket-Protocol")
260+
.unwrap()
261+
.to_str()
262+
.unwrap(),
263+
"fido.cable"
264+
);
265+
assert!(request.headers().get("X-caBLE-Client-Payload").is_some());
266+
}
267+
268+
#[test]
269+
fn build_tunnel_request_omits_payload_for_qr_code() {
270+
let connection_type = qr_connection_type();
271+
let request = build_tunnel_request(
272+
"wss://cable.example.com/cable/connect/aabbcc/0011",
273+
&connection_type,
274+
)
275+
.unwrap();
276+
assert_eq!(
277+
request
278+
.headers()
279+
.get("Sec-WebSocket-Protocol")
280+
.unwrap()
281+
.to_str()
282+
.unwrap(),
283+
"fido.cable"
284+
);
285+
assert!(request.headers().get("X-caBLE-Client-Payload").is_none());
286+
}
287+
288+
#[test]
289+
fn gone_forgets_known_device() {
290+
let public_key = vec![7u8; 65];
291+
let connection_type = known_device_connection_type(public_key.clone());
292+
assert_eq!(
293+
known_device_id_to_forget(
294+
&TransportError::CableTunnel(CableTunnelError::Gone),
295+
&connection_type
296+
),
297+
Some(hex::encode(&public_key))
298+
);
299+
}
300+
301+
#[test]
302+
fn gone_does_not_forget_qr_code() {
303+
let connection_type = qr_connection_type();
304+
assert_eq!(
305+
known_device_id_to_forget(
306+
&TransportError::CableTunnel(CableTunnelError::Gone),
307+
&connection_type
308+
),
309+
None
310+
);
311+
}
312+
313+
#[test]
314+
fn non_gone_error_does_not_forget_known_device() {
315+
let connection_type = known_device_connection_type(vec![7u8; 65]);
316+
assert_eq!(
317+
known_device_id_to_forget(&TransportError::ConnectionFailed, &connection_type),
318+
None
319+
);
320+
}
321+
322+
#[test]
323+
fn gone_status_maps_to_distinct_error() {
324+
assert_eq!(
325+
tunnel_status_error(StatusCode::GONE),
326+
TransportError::CableTunnel(CableTunnelError::Gone)
327+
);
328+
assert_eq!(
329+
tunnel_status_error(StatusCode::BAD_GATEWAY),
330+
TransportError::CableTunnel(CableTunnelError::UnexpectedStatus(502))
331+
);
332+
}
134333
}

0 commit comments

Comments
 (0)