diff --git a/README.md b/README.md index 7fbb3863..c1230d38 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,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 (caBLE v2): Linger after the ceremony to capture the linking update - 🟢 Hybrid transport (CTAP 2.3): direct BLE L2CAP data channel, QR-initiated, no tunnel server ## Runtime requirements diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index a59328b2..79bb67d7 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -120,6 +120,8 @@ reqwest = { version = "0.12", default-features = false, features = [ [dev-dependencies] tracing-subscriber = { version = "0.3.3", features = ["env-filter"] } qrcode = "0.14.1" +# test-util enables paused time for deterministic timeout/linger tests +tokio = { version = "1.45", features = ["test-util"] } # For turning on logging in unittests test-log = { version = "0.2" } diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 975c3a67..8b499c0e 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -3,10 +3,10 @@ //! MakeCredential only. use std::error::Error; -use libwebauthn::transport::cable::is_available; use libwebauthn::transport::cable::qr_code_device::{ CableQrCodeDevice, CableTransports, QrCodeOperationHint, }; +use libwebauthn::transport::cable::{is_available, CableClose}; use qrcode::render::unicode; use qrcode::QrCode; @@ -96,5 +96,7 @@ pub async fn main() -> Result<(), Box> { .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); + // A transient QR code never lingers, so this is a plain graceful close. + channel.close(CableClose::Immediate).await; Ok(()) } diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs index e9555583..458ba3be 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs @@ -2,13 +2,15 @@ use std::error::Error; use std::sync::Arc; use std::time::Duration; -use libwebauthn::transport::cable::is_available; use libwebauthn::transport::cable::known_devices::{ CableKnownDevice, ClientPayloadHint, EphemeralDeviceInfoStore, }; use libwebauthn::transport::cable::qr_code_device::{ CableQrCodeDevice, CableTransports, QrCodeOperationHint, }; +use libwebauthn::transport::cable::{ + is_available, CableClose, CableLingerConfig, CableLingerRegistry, +}; use qrcode::render::unicode; use qrcode::QrCode; use tokio::time::sleep; @@ -68,6 +70,14 @@ pub async fn main() -> Result<(), Box> { } let device_info_store = Arc::new(EphemeralDeviceInfoStore::default()); + // One registry per client, threaded through every hybrid channel. It lets + // a connection keep receiving the linking update after the ceremony, and + // a new connection evict the one still lingering. + let linger_registry = CableLingerRegistry::new(); + let settings = || ChannelSettings { + cable_linger: Some(CableLingerConfig::new(linger_registry.clone())), + ..Default::default() + }; let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin"); let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", @@ -89,7 +99,7 @@ pub async fn main() -> Result<(), Box> { .build(); println!("{}", image); - let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); + let mut channel = device.channel(settings()).await.unwrap(); println!("Channel established {:?}", channel); let state_recv = channel.get_ux_update_receiver(); @@ -113,10 +123,18 @@ pub async fn main() -> Result<(), Box> { .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); + + // Say goodbye, then keep receiving in the background: the phone may + // send its linking information a while after the response. + channel.close(CableClose::Linger).await; } - println!("Waiting for 5 seconds before contacting the device..."); + println!("Waiting for 5 seconds for a linking update..."); sleep(Duration::from_secs(5)).await; + println!( + "Connections still lingering: {}", + linger_registry.lingering_count() + ); // Second leg: prefer state-assisted reconnection if the peer offered // linking info, otherwise fall back to a fresh QR. Many authenticators @@ -131,12 +149,11 @@ pub async fn main() -> Result<(), Box> { ) .await .unwrap(); - let mut channel = known_device - .channel(ChannelSettings::default()) - .await - .unwrap(); + // Opening this channel evicts the lingering QR connection. + let mut channel = known_device.channel(settings()).await.unwrap(); println!("Channel established {:?}", channel); run_get_assertion(&mut channel, &request_origin, &psl).await?; + channel.close(CableClose::Immediate).await; } else { println!("No known devices (peer did not offer linking). Falling back to QR."); let mut device: CableQrCodeDevice = CableQrCodeDevice::new_persistent( @@ -151,11 +168,15 @@ pub async fn main() -> Result<(), Box> { .light_color(unicode::Dense1x2::Dark) .build(); println!("{}", image); - let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); + let mut channel = device.channel(settings()).await.unwrap(); println!("Channel established {:?}", channel); run_get_assertion(&mut channel, &request_origin, &psl).await?; + // Nothing follows that could use a linking update, so just close. + channel.close(CableClose::Immediate).await; } + // Signal any lingering connection to stop before the runtime goes away. + linger_registry.close_lingering(); Ok(()) } diff --git a/libwebauthn/examples/management/persistent_cred_management_hid.rs b/libwebauthn/examples/management/persistent_cred_management_hid.rs index 3505721a..49b41983 100644 --- a/libwebauthn/examples/management/persistent_cred_management_hid.rs +++ b/libwebauthn/examples/management/persistent_cred_management_hid.rs @@ -42,6 +42,7 @@ pub async fn main() -> Result<(), WebAuthnError> { // token through it. The same settings apply to any transport. let settings = ChannelSettings { persistent_token_store: Some(store.clone()), + ..Default::default() }; let mut channel = device.channel(settings).await?; let state_recv = channel.get_ux_update_receiver(); diff --git a/libwebauthn/src/transport/cable/channel.rs b/libwebauthn/src/transport/cable/channel.rs index 5bc1cacb..d3f488b6 100644 --- a/libwebauthn/src/transport/cable/channel.rs +++ b/libwebauthn/src/transport/cable/channel.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use tokio::sync::{broadcast, mpsc, watch}; use tokio::{task, time}; -use tracing::error; +use tracing::{debug, error, warn}; use crate::pin::persistent_token::PersistentTokenStore; use crate::proto::{ @@ -22,18 +22,37 @@ use crate::Transport; use crate::UvUpdate; use super::known_devices::CableKnownDevice; +use super::linger::Teardown; use super::qr_code_device::CableQrCodeDevice; +/// Bounds `close()`: the Shutdown send plus the task's return. +const CLOSE_FLUSH_TIMEOUT: Duration = Duration::from_secs(5); +/// Bounds `cancel()`: one select hop plus a socket drop. Aborts on expiry. +const CANCEL_TIMEOUT: Duration = Duration::from_secs(2); + #[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] pub enum ConnectionState { /// Connection is being established (proximity check, connecting, authenticating) Connecting, /// Connection is fully established and ready for operations Connected, + /// Shutdown has been sent and the connection is only receiving a late + /// linking update. No further operations are admitted. + Lingering, /// Connection has terminated Terminated, } +/// How [`CableChannel::close`] ends the connection. Both send Shutdown first. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CableClose { + /// Terminate as soon as Shutdown has been sent. + Immediate, + /// Keep receiving in the background to capture a late linking update. + Linger, +} + #[derive(Debug)] pub enum CableChannelDevice<'d> { QrCode(&'d CableQrCodeDevice), @@ -48,9 +67,95 @@ pub struct CableChannel { pub(crate) ux_update_sender: broadcast::Sender, pub(crate) connection_state_receiver: watch::Receiver, pub(crate) persistent_token_store: Option>, + pub(crate) teardown: Arc>, + pub(crate) linger_eligible: bool, } impl CableChannel { + /// Sends Shutdown, then ends the connection as `mode` says. The + /// [`Channel::close`] of this channel is [`CableClose::Immediate`]. + /// + /// [`CableClose::Immediate`] waits for the connection to terminate, + /// cancelling it if that takes too long. A linger already under way is + /// left alone. + /// + /// [`CableClose::Linger`] returns once the connection is lingering, not + /// when the window ends, and the channel can then be dropped. Only a + /// state-assisted QR connection opened with a + /// [`CableLingerConfig`](super::CableLingerConfig) can linger, anything + /// else closes immediately. The linger runs on the tokio runtime that + /// opened the channel, which must outlive the window. Call + /// [`CableLingerRegistry::close_lingering`](super::CableLingerRegistry::close_lingering) + /// before shutting that runtime down. + pub async fn close(&mut self, mode: CableClose) { + match mode { + CableClose::Immediate => self.close_immediately().await, + CableClose::Linger if self.linger_eligible => self.linger().await, + CableClose::Linger => self.close_immediately().await, + } + } + + async fn close_immediately(&mut self) { + self.request_teardown(Teardown::Close); + if *self.teardown.borrow() == Teardown::Linger { + self.wait_for_state(CLOSE_FLUSH_TIMEOUT, |state| { + matches!( + state, + ConnectionState::Lingering | ConnectionState::Terminated + ) + }) + .await; + return; + } + if !self + .wait_for_state(CLOSE_FLUSH_TIMEOUT, |state| { + *state == ConnectionState::Terminated + }) + .await + { + warn!("Timed out waiting for the hybrid connection to close, cancelling it"); + self.cancel().await; + } + } + + async fn linger(&mut self) { + self.request_teardown(Teardown::Linger); + if !self + .wait_for_state(CLOSE_FLUSH_TIMEOUT, |state| { + matches!( + state, + ConnectionState::Lingering | ConnectionState::Terminated + ) + }) + .await + { + warn!("Timed out waiting for the hybrid connection to start lingering"); + } + } + + /// Sets the teardown intent if nobody has set one yet. Returns whether it did. + fn request_teardown(&self, intent: Teardown) -> bool { + self.teardown.send_if_modified(|current| { + if *current == Teardown::Active { + *current = intent; + true + } else { + false + } + }) + } + + /// Waits until the connection reaches a state matching `done`, bounded by `timeout`. + async fn wait_for_state( + &self, + timeout: Duration, + done: impl FnMut(&ConnectionState) -> bool, + ) -> bool { + let mut rx = self.connection_state_receiver.clone(); + let reached = time::timeout(timeout, rx.wait_for(done)).await.is_ok(); + reached + } + async fn wait_for_connection(&self) -> Result<(), CableError> { let mut rx = self.connection_state_receiver.clone(); @@ -64,8 +169,10 @@ impl CableChannel { // surfaces the same variant as one that terminates while we wait; // the caller can't observe the timing difference and the asymmetry // was accidental. - if *rx.borrow() == ConnectionState::Terminated { - return Err(CableError::ConnectionFailed); + match *rx.borrow() { + ConnectionState::Terminated => return Err(CableError::ConnectionFailed), + ConnectionState::Lingering => return Err(CableError::ConnectionLost), + _ => {} } // Wait for state change @@ -73,6 +180,7 @@ impl CableChannel { match *rx.borrow() { ConnectionState::Connected => return Ok(()), ConnectionState::Terminated => return Err(CableError::ConnectionFailed), + ConnectionState::Lingering => return Err(CableError::ConnectionLost), ConnectionState::Connecting => continue, } } @@ -90,7 +198,11 @@ impl Display for CableChannel { impl Drop for CableChannel { fn drop(&mut self) { - self.handle_connection.abort(); + // An unattended drop is a hard cancel. A teardown already under way + // (close, linger, cancel) is left to run its course. + if self.request_teardown(Teardown::Cancel) { + self.handle_connection.abort(); + } } } @@ -134,14 +246,32 @@ impl Channel for CableChannel { } async fn status(&self) -> ChannelStatus { - match self.handle_connection.is_finished() { - true => ChannelStatus::Closed, - false => ChannelStatus::Ready, + if self.handle_connection.is_finished() { + return ChannelStatus::Closed; + } + match *self.connection_state_receiver.borrow() { + ConnectionState::Lingering | ConnectionState::Terminated => ChannelStatus::Closed, + _ => ChannelStatus::Ready, } } async fn close(&mut self) { - // TODO Send CableTunnelMessageType#Shutdown and drop the connection + CableChannel::close(self, CableClose::Immediate).await + } + + /// Drops the connection without sending Shutdown. Always wins over a + /// graceful teardown already under way. + async fn cancel(&mut self) { + self.teardown.send_replace(Teardown::Cancel); + if !self + .wait_for_state(CANCEL_TIMEOUT, |state| { + *state == ConnectionState::Terminated + }) + .await + { + debug!("Aborting the hybrid connection task after cancel timeout"); + self.handle_connection.abort(); + } } async fn apdu_send( @@ -224,3 +354,251 @@ impl Ctap2AuthTokenStore for CableChannel { self.persistent_token_store.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn channel_in_state(state: ConnectionState) -> (CableChannel, watch::Sender) { + let (ux_update_sender, _) = broadcast::channel(1); + let (cbor_sender, _cbor_tx_recv) = mpsc::channel(1); + let (_cbor_rx_send, cbor_receiver) = mpsc::channel(1); + let (teardown, _teardown_rx) = watch::channel(Teardown::Active); + let (state_tx, connection_state_receiver) = watch::channel(state); + let channel = CableChannel { + handle_connection: task::spawn(std::future::pending()), + cbor_sender, + cbor_receiver, + ux_update_sender, + connection_state_receiver, + persistent_token_store: None, + teardown: Arc::new(teardown), + linger_eligible: true, + }; + (channel, state_tx) + } + + /// A channel whose task mimics the connection loop's teardown handling: + /// it publishes `Terminated` on any intent and reports the intent seen. + fn channel_with_teardown_task() -> ( + CableChannel, + tokio::sync::oneshot::Receiver, + Arc>, + ) { + let (ux_update_sender, _) = broadcast::channel(1); + let (cbor_sender, _cbor_tx_recv) = mpsc::channel(1); + let (_cbor_rx_send, cbor_receiver) = mpsc::channel(1); + let (teardown, mut teardown_rx) = watch::channel(Teardown::Active); + let teardown = Arc::new(teardown); + let (state_tx, connection_state_receiver) = watch::channel(ConnectionState::Connected); + let (seen_tx, seen_rx) = tokio::sync::oneshot::channel(); + let handle_connection = task::spawn(async move { + let intent = super::super::connection_stages::next_teardown(&mut teardown_rx).await; + let _ = seen_tx.send(intent); + let _ = state_tx.send(ConnectionState::Terminated); + }); + let channel = CableChannel { + handle_connection, + cbor_sender, + cbor_receiver, + ux_update_sender, + connection_state_receiver, + persistent_token_store: None, + teardown: teardown.clone(), + linger_eligible: true, + }; + (channel, seen_rx, teardown) + } + + #[tokio::test] + async fn linger_requests_linger_on_an_eligible_channel() { + let (mut channel, seen_rx, teardown) = channel_with_teardown_task(); + channel.close(CableClose::Linger).await; + assert_eq!(seen_rx.await.unwrap(), Teardown::Linger); + assert_eq!(*teardown.borrow(), Teardown::Linger); + } + + #[tokio::test] + async fn linger_degrades_to_close_on_an_ineligible_channel() { + let (mut channel, seen_rx, teardown) = channel_with_teardown_task(); + channel.linger_eligible = false; + channel.close(CableClose::Linger).await; + assert_eq!(seen_rx.await.unwrap(), Teardown::Close); + assert_eq!(*teardown.borrow(), Teardown::Close); + } + + #[tokio::test] + async fn drop_after_linger_does_not_cancel() { + let (mut channel, seen_rx, teardown) = channel_with_teardown_task(); + channel.close(CableClose::Linger).await; + drop(channel); + assert_eq!(*teardown.borrow(), Teardown::Linger); + assert_eq!(seen_rx.await.unwrap(), Teardown::Linger); + } + + #[tokio::test] + async fn close_requests_graceful_close_and_waits_for_termination() { + let (mut channel, seen_rx, teardown) = channel_with_teardown_task(); + channel.close(CableClose::Immediate).await; + assert_eq!( + *channel.connection_state_receiver.borrow(), + ConnectionState::Terminated + ); + assert_eq!(seen_rx.await.unwrap(), Teardown::Close); + assert_eq!(*teardown.borrow(), Teardown::Close); + assert!(matches!(channel.status().await, ChannelStatus::Closed)); + } + + #[tokio::test] + async fn cancel_requests_hard_cancel() { + let (mut channel, seen_rx, teardown) = channel_with_teardown_task(); + channel.cancel().await; + assert_eq!(seen_rx.await.unwrap(), Teardown::Cancel); + assert_eq!(*teardown.borrow(), Teardown::Cancel); + } + + #[tokio::test] + async fn cancel_overrides_a_close_in_progress() { + let (mut channel, _seen_rx, teardown) = channel_with_teardown_task(); + assert!(channel.request_teardown(Teardown::Close)); + channel.cancel().await; + assert_eq!(*teardown.borrow(), Teardown::Cancel); + } + + #[tokio::test] + async fn unattended_drop_cancels_and_aborts() { + let (channel, seen_rx, teardown) = channel_with_teardown_task(); + drop(channel); + assert_eq!(*teardown.borrow(), Teardown::Cancel); + // The task was aborted, so it never reported the intent it saw. + assert!(seen_rx.await.is_err()); + } + + #[tokio::test] + async fn drop_after_close_does_not_downgrade_the_intent() { + let (mut channel, seen_rx, teardown) = channel_with_teardown_task(); + channel.close(CableClose::Immediate).await; + drop(channel); + assert_eq!(*teardown.borrow(), Teardown::Close); + assert_eq!(seen_rx.await.unwrap(), Teardown::Close); + } + + #[tokio::test(start_paused = true)] + async fn cancel_aborts_a_task_that_ignores_the_intent() { + let (mut channel, _state_tx) = channel_in_state(ConnectionState::Connected); + let started = time::Instant::now(); + channel.cancel().await; + assert_eq!(started.elapsed(), CANCEL_TIMEOUT); + let joined = (&mut channel.handle_connection).await; + assert!(joined.unwrap_err().is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn close_escalates_to_cancel_after_the_flush_timeout() { + let (mut channel, _state_tx) = channel_in_state(ConnectionState::Connected); + let started = time::Instant::now(); + channel.close(CableClose::Immediate).await; + assert_eq!(started.elapsed(), CLOSE_FLUSH_TIMEOUT + CANCEL_TIMEOUT); + assert_eq!(*channel.teardown.borrow(), Teardown::Cancel); + let joined = (&mut channel.handle_connection).await; + assert!(joined.unwrap_err().is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn linger_gives_up_after_the_flush_timeout_without_aborting() { + let (mut channel, _state_tx) = channel_in_state(ConnectionState::Connected); + let started = time::Instant::now(); + channel.close(CableClose::Linger).await; + assert_eq!(started.elapsed(), CLOSE_FLUSH_TIMEOUT); + assert_eq!(*channel.teardown.borrow(), Teardown::Linger); + assert!(!channel.handle_connection.is_finished()); + } + + /// A channel whose task publishes `Lingering` on the linger intent and + /// then stays alive, like the real linger phase. + fn channel_with_lingering_task() -> (CableChannel, task::AbortHandle) { + let (ux_update_sender, _) = broadcast::channel(1); + let (cbor_sender, _cbor_tx_recv) = mpsc::channel(1); + let (_cbor_rx_send, cbor_receiver) = mpsc::channel(1); + let (teardown, mut teardown_rx) = watch::channel(Teardown::Active); + let (state_tx, connection_state_receiver) = watch::channel(ConnectionState::Connected); + let handle_connection = task::spawn(async move { + let intent = super::super::connection_stages::next_teardown(&mut teardown_rx).await; + if intent == Teardown::Linger { + let _ = state_tx.send(ConnectionState::Lingering); + std::future::pending::<()>().await; + } + let _ = state_tx.send(ConnectionState::Terminated); + }); + let abort = handle_connection.abort_handle(); + let channel = CableChannel { + handle_connection, + cbor_sender, + cbor_receiver, + ux_update_sender, + connection_state_receiver, + persistent_token_store: None, + teardown: Arc::new(teardown), + linger_eligible: true, + }; + (channel, abort) + } + + #[tokio::test(start_paused = true)] + async fn linger_returns_once_lingering_and_survives_drop() { + let (mut channel, abort) = channel_with_lingering_task(); + let started = time::Instant::now(); + channel.close(CableClose::Linger).await; + assert_eq!(started.elapsed(), Duration::ZERO); + assert!(matches!(channel.status().await, ChannelStatus::Closed)); + + drop(channel); + task::yield_now().await; + assert!(!abort.is_finished(), "the linger outlives the channel"); + abort.abort(); + } + + #[tokio::test(start_paused = true)] + async fn close_after_linger_does_not_cut_the_linger_short() { + let (mut channel, abort) = channel_with_lingering_task(); + channel.close(CableClose::Linger).await; + let started = time::Instant::now(); + channel.close(CableClose::Immediate).await; + assert_eq!(started.elapsed(), Duration::ZERO); + assert_eq!(*channel.teardown.borrow(), Teardown::Linger); + assert!(!abort.is_finished()); + abort.abort(); + } + + #[tokio::test] + async fn wait_for_connection_rejects_lingering() { + let (channel, _state_tx) = channel_in_state(ConnectionState::Lingering); + assert!(matches!( + channel.wait_for_connection().await, + Err(CableError::ConnectionLost) + )); + } + + #[tokio::test] + async fn wait_for_connection_rejects_transition_to_lingering() { + let (channel, state_tx) = channel_in_state(ConnectionState::Connecting); + let waiter = tokio::spawn(async move { channel.wait_for_connection().await }); + state_tx.send(ConnectionState::Lingering).unwrap(); + assert!(matches!( + waiter.await.unwrap(), + Err(CableError::ConnectionLost) + )); + } + + #[tokio::test] + async fn status_maps_lingering_to_closed() { + let (channel, _state_tx) = channel_in_state(ConnectionState::Lingering); + assert!(matches!(channel.status().await, ChannelStatus::Closed)); + } + + #[tokio::test] + async fn status_maps_connected_to_ready() { + let (channel, _state_tx) = channel_in_state(ConnectionState::Connected); + assert!(matches!(channel.status().await, ChannelStatus::Ready)); + } +} diff --git a/libwebauthn/src/transport/cable/connection_stages.rs b/libwebauthn/src/transport/cable/connection_stages.rs index 740c64bc..3bbcbeb3 100644 --- a/libwebauthn/src/transport/cable/connection_stages.rs +++ b/libwebauthn/src/transport/cable/connection_stages.rs @@ -9,12 +9,14 @@ use super::crypto::{derive, KeyPurpose}; use super::data_channel::{CableDataChannel, WebSocketDataChannel}; use super::known_devices::{CableKnownDevice, CableKnownDeviceInfoStore, ClientNonce}; use super::l2cap::L2capDataChannel; +use super::linger::{LingerParams, Teardown}; use super::protocol::{self, CableTunnelConnectionType, TunnelNoiseState}; use super::qr_code_device::CableQrCodeDevice; use super::tunnel; use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; use crate::transport::ble::btleplug::FidoDevice; use crate::transport::cable::error::CableError; +use std::future::Future; use std::sync::Arc; #[derive(Debug)] @@ -201,6 +203,9 @@ pub(crate) struct TunnelConnectionInput { pub noise_state: TunnelNoiseState, pub cbor_tx_recv: mpsc::Receiver, pub cbor_rx_send: mpsc::Sender, + pub teardown_rx: watch::Receiver, + /// Present only when this connection may linger after Shutdown. + pub linger: Option, } impl TunnelConnectionInput { @@ -209,6 +214,8 @@ impl TunnelConnectionInput { known_device_store: Option>, cbor_tx_recv: mpsc::Receiver, cbor_rx_send: mpsc::Sender, + teardown_rx: watch::Receiver, + linger: Option, ) -> Self { Self { connection_type: handshake_output.connection_type, @@ -218,10 +225,35 @@ impl TunnelConnectionInput { noise_state: handshake_output.noise_state, cbor_tx_recv, cbor_rx_send, + teardown_rx, + linger, } } } +/// Waits for the next teardown intent. Every sender gone counts as a cancel, +/// since nobody is left to ask for a graceful close. +pub(crate) async fn next_teardown(teardown_rx: &mut watch::Receiver) -> Teardown { + match teardown_rx.changed().await { + Ok(()) => *teardown_rx.borrow_and_update(), + Err(_) => Teardown::Cancel, + } +} + +/// Drives the connect and handshake stages until they complete or the caller +/// tears the channel down. There is no secure channel yet, so any intent +/// simply drops the in-flight future. +pub(crate) async fn until_teardown( + fut: F, + teardown_rx: &mut watch::Receiver, +) -> Option { + tokio::select! { + biased; + _ = next_teardown(teardown_rx) => None, + output = fut => Some(output), + } +} + #[async_trait] pub(crate) trait UxUpdateSender: Send + Sync { async fn send_update(&self, update: CableUxUpdate); @@ -395,3 +427,38 @@ pub(crate) fn decode_tunnel_domain_from_advert( CableError::InvalidFraming }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn until_teardown_drops_a_pending_connect_on_any_intent() { + let (tx, mut rx) = watch::channel(Teardown::Active); + let started = tokio::time::Instant::now(); + let connect = until_teardown(std::future::pending::<()>(), &mut rx); + tx.send_replace(Teardown::Close); + assert!(connect.await.is_none()); + assert_eq!(started.elapsed(), std::time::Duration::ZERO); + } + + #[tokio::test] + async fn until_teardown_yields_the_output_when_undisturbed() { + let (_tx, mut rx) = watch::channel(Teardown::Active); + assert_eq!(until_teardown(async { 7 }, &mut rx).await, Some(7)); + } + + #[tokio::test] + async fn until_teardown_prefers_an_intent_over_a_ready_connect() { + let (tx, mut rx) = watch::channel(Teardown::Active); + tx.send_replace(Teardown::Cancel); + assert_eq!(until_teardown(async { 7 }, &mut rx).await, None); + } + + #[tokio::test] + async fn next_teardown_treats_a_dropped_sender_as_cancel() { + let (tx, mut rx) = watch::channel(Teardown::Active); + drop(tx); + assert_eq!(next_teardown(&mut rx).await, Teardown::Cancel); + } +} diff --git a/libwebauthn/src/transport/cable/known_devices.rs b/libwebauthn/src/transport/cable/known_devices.rs index 01d42cdc..c27cf981 100644 --- a/libwebauthn/src/transport/cable/known_devices.rs +++ b/libwebauthn/src/transport/cable/known_devices.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use crate::transport::cable::channel::ConnectionState; use crate::transport::cable::connection_stages::{ - connection_stage, handshake_stage, proximity_check_stage, ConnectionInput, HandshakeInput, - HandshakeOutput, MpscUxUpdateSender, ProximityCheckInput, TunnelConnectionInput, - UxUpdateSender, + connection_stage, handshake_stage, proximity_check_stage, until_teardown, ConnectionInput, + HandshakeInput, HandshakeOutput, MpscUxUpdateSender, ProximityCheckInput, + TunnelConnectionInput, UxUpdateSender, }; use crate::transport::cable::error::CableError; @@ -24,6 +24,7 @@ use tokio::task; use tracing::{debug, instrument, trace}; use super::channel::CableChannel; +use super::linger::Teardown; use super::protocol::{self, CableLinkingInfo}; use super::Cable; @@ -203,6 +204,16 @@ impl<'d> Device<'d, Cable, CableChannel> for CableKnownDevice { let (cbor_rx_send, cbor_rx_recv) = mpsc::channel(16); let (connection_state_sender, connection_state_receiver) = watch::channel(ConnectionState::Connecting); + let (teardown_tx, teardown_rx) = watch::channel(Teardown::Active); + let teardown_tx = Arc::new(teardown_tx); + let mut teardown_rx_connect = teardown_rx.clone(); + + // A new connection supersedes any connection still lingering. Known + // device connections never linger themselves: their linking update + // cannot be verified and is discarded. + if let Some(config) = &settings.cable_linger { + config.registry.close_lingering(); + } let ux_update_sender_clone = ux_update_sender.clone(); let known_device: CableKnownDevice = self.clone(); @@ -211,12 +222,21 @@ impl<'d> Device<'d, Cable, CableChannel> for CableKnownDevice { let ux_sender = MpscUxUpdateSender::new(ux_update_sender_clone, connection_state_sender); - let handshake_output = match Self::connection(&known_device, &ux_sender).await { - Ok(handshake_output) => handshake_output, - Err(e) => { + let connecting = Self::connection(&known_device, &ux_sender); + let handshake_output = match until_teardown(connecting, &mut teardown_rx_connect).await + { + Some(Ok(handshake_output)) => handshake_output, + Some(Err(e)) => { ux_sender.send_error(e).await; return; } + None => { + debug!("Hybrid connection torn down before the handshake completed"); + ux_sender + .set_connection_state(ConnectionState::Terminated) + .await; + return; + } }; let tunnel_input = TunnelConnectionInput::from_handshake_output( @@ -224,9 +244,11 @@ impl<'d> Device<'d, Cable, CableChannel> for CableKnownDevice { Some(known_device.store), cbor_tx_recv, cbor_rx_send, + teardown_rx, + None, ); - match protocol::connection(tunnel_input).await { + match protocol::connection(tunnel_input, &ux_sender).await { Ok(()) => { ux_sender .set_connection_state(ConnectionState::Terminated) @@ -246,6 +268,8 @@ impl<'d> Device<'d, Cable, CableChannel> for CableKnownDevice { ux_update_sender, connection_state_receiver, persistent_token_store: settings.persistent_token_store, + teardown: teardown_tx, + linger_eligible: false, }) } } diff --git a/libwebauthn/src/transport/cable/linger.rs b/libwebauthn/src/transport/cable/linger.rs new file mode 100644 index 00000000..5acea7f3 --- /dev/null +++ b/libwebauthn/src/transport/cable/linger.rs @@ -0,0 +1,333 @@ +//! Caller-driven teardown of hybrid connections, and the registry that tracks +//! connections left lingering for a late linking update. + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use tokio::sync::watch; + +/// Concurrent detached lingerers per registry. The oldest is evicted on overflow. +pub(crate) const MAX_LINGERING: usize = 8; + +/// Caller to task teardown intent. One watch per connection, distinct from +/// [`ConnectionState`](super::channel::ConnectionState), which is the task to +/// caller phase. Cancel always wins and no intent is ever downgraded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Teardown { + /// Running normally: connecting, or in the active loop. + Active, + /// Graceful: send Shutdown, then terminate. + Close, + /// Graceful: send Shutdown, then linger for a late linking update if eligible. + Linger, + /// Hard: no Shutdown, terminate now. + Cancel, +} + +/// Opt-in to lingering, carried on +/// [`ChannelSettings::cable_linger`](crate::transport::ChannelSettings::cable_linger). +/// +/// After a QR-initiated ceremony the authenticator may send its linking +/// information a while after the CTAP response. Capturing it needs the +/// connection to stay open after the caller is done with the channel, which +/// only happens when the caller closes the channel with +/// [`CableClose::Linger`](super::channel::CableClose::Linger) once the +/// ceremony has completed. An immediate close or a drop captures nothing. +/// +/// Carrying a config also makes opening a new hybrid channel evict any +/// connection still lingering in the same [`CableLingerRegistry`]. +#[derive(Debug, Clone)] +pub struct CableLingerConfig { + /// Tracks lingering connections across ceremonies. Thread the same instance + /// through every hybrid `channel()` call of one logical client. + pub registry: CableLingerRegistry, + /// How long to keep receiving after Shutdown. Clamped to [`Self::HARD_CAP`]. + pub linger_duration: Duration, +} + +impl CableLingerConfig { + /// Default linger window. The spec asks for at least two minutes after Shutdown. + pub const DEFAULT_DURATION: Duration = Duration::from_secs(120); + /// Absolute ceiling on a linger, whatever the configured window. Matches Chromium. + pub const HARD_CAP: Duration = Duration::from_secs(180); + + pub fn new(registry: CableLingerRegistry) -> Self { + Self { + registry, + linger_duration: Self::DEFAULT_DURATION, + } + } +} + +#[derive(Default)] +struct RegistryInner { + next_id: u64, + entries: BTreeMap>>, +} + +impl RegistryInner { + fn is_lingering(tx: &watch::Sender) -> bool { + *tx.borrow() == Teardown::Linger + } +} + +impl Drop for RegistryInner { + fn drop(&mut self) { + // Connections still in use stay owned by their channel. + for tx in self.entries.values().filter(|tx| Self::is_lingering(tx)) { + tx.send_replace(Teardown::Cancel); + } + } +} + +/// Tracks hybrid connections from creation so that a lingering one can be +/// evicted after its channel is gone. Cheap to clone. +/// +/// Close-on-new and eviction only apply to channels opened with the same +/// registry instance. A channel opened without it neither evicts nor can be +/// evicted, so use one registry per logical client, and independent registries +/// for independent concurrent clients. The caller holds the only strong +/// references: dropping the last clone cancels every connection that is +/// lingering, and a connection whose registry is gone by the time it would +/// linger closes instead. Connections still in use are never affected. +#[derive(Clone, Default)] +pub struct CableLingerRegistry { + inner: Arc>, +} + +impl CableLingerRegistry { + pub fn new() -> Self { + Self::default() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, RegistryInner> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Connections currently lingering. Eventually consistent with the + /// natural expiry of a linger window. + pub fn lingering_count(&self) -> usize { + self.lock() + .entries + .values() + .filter(|tx| RegistryInner::is_lingering(tx)) + .count() + } + + /// Cancels every lingering connection. Returns how many were signalled. + /// Connections still connecting or in use are left alone. + pub fn close_lingering(&self) -> usize { + let inner = self.lock(); + let lingering: Vec<_> = inner + .entries + .values() + .filter(|tx| RegistryInner::is_lingering(tx)) + .collect(); + for tx in &lingering { + tx.send_replace(Teardown::Cancel); + } + lingering.len() + } + + /// Registers a connection at creation time. Over [`MAX_LINGERING`], the + /// oldest lingering connection is cancelled to make room. + pub(crate) fn register(&self, tx: Arc>) -> RegistryGuard { + let mut inner = self.lock(); + if inner.entries.len() >= MAX_LINGERING { + let oldest = inner + .entries + .iter() + .find(|(_, tx)| RegistryInner::is_lingering(tx)) + .map(|(id, _)| *id); + if let Some(id) = oldest { + if let Some(evicted) = inner.entries.remove(&id) { + evicted.send_replace(Teardown::Cancel); + } + } + } + let id = inner.next_id; + inner.next_id += 1; + inner.entries.insert(id, tx); + RegistryGuard { + inner: Arc::downgrade(&self.inner), + id, + } + } +} + +impl fmt::Debug for CableLingerRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CableLingerRegistry") + .field("lingering_count", &self.lingering_count()) + .finish() + } +} + +/// Removes the connection from its registry when the connection task ends, +/// however it ends. Holds a weak reference so it never keeps the registry alive. +pub(crate) struct RegistryGuard { + inner: Weak>, + id: u64, +} + +impl RegistryGuard { + /// Whether the registry still exists. Without it a linger would be + /// untracked, so the connection closes instead. + pub(crate) fn is_live(&self) -> bool { + self.inner.strong_count() > 0 + } +} + +impl Drop for RegistryGuard { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .entries + .remove(&self.id); + } + } +} + +/// What the connection task needs to linger. Present only for connections +/// that are eligible and tracked by a registry. +pub(crate) struct LingerParams { + pub linger_duration: Duration, + #[allow(dead_code)] + pub guard: RegistryGuard, +} + +impl LingerParams { + /// Builds the linger parameters for a connection, registering it. `None` + /// when the caller did not opt in or the connection is not eligible. + pub(crate) fn new( + config: Option<&CableLingerConfig>, + eligible: bool, + tx: &Arc>, + ) -> Option { + let config = config?; + if !eligible { + return None; + } + Some(Self { + linger_duration: config.linger_duration.min(CableLingerConfig::HARD_CAP), + guard: config.registry.register(tx.clone()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(registry: &CableLingerRegistry) -> (Arc>, RegistryGuard) { + let (tx, _rx) = watch::channel(Teardown::Active); + let tx = Arc::new(tx); + let guard = registry.register(tx.clone()); + (tx, guard) + } + + #[test] + fn close_lingering_cancels_only_lingerers() { + let registry = CableLingerRegistry::new(); + let (lingering, _g1) = entry(®istry); + let (active, _g2) = entry(®istry); + lingering.send_replace(Teardown::Linger); + + assert_eq!(registry.lingering_count(), 1); + assert_eq!(registry.close_lingering(), 1); + assert_eq!(*lingering.borrow(), Teardown::Cancel); + assert_eq!(*active.borrow(), Teardown::Active); + assert_eq!(registry.lingering_count(), 0); + } + + #[test] + fn guard_drop_deregisters() { + let registry = CableLingerRegistry::new(); + let (tx, guard) = entry(®istry); + tx.send_replace(Teardown::Linger); + assert_eq!(registry.lingering_count(), 1); + drop(guard); + assert_eq!(registry.lingering_count(), 0); + assert_eq!(registry.close_lingering(), 0); + } + + #[test] + fn overflow_evicts_the_oldest_lingerer() { + let registry = CableLingerRegistry::new(); + let mut entries = Vec::new(); + for _ in 0..MAX_LINGERING { + let (tx, guard) = entry(®istry); + tx.send_replace(Teardown::Linger); + entries.push((tx, guard)); + } + let (newest, _guard) = entry(®istry); + + assert_eq!(*entries[0].0.borrow(), Teardown::Cancel); + assert_eq!(*entries[1].0.borrow(), Teardown::Linger); + assert_eq!(*newest.borrow(), Teardown::Active); + assert_eq!(registry.lingering_count(), MAX_LINGERING - 1); + } + + #[test] + fn overflow_never_evicts_an_active_connection() { + let registry = CableLingerRegistry::new(); + let mut entries = Vec::new(); + for _ in 0..MAX_LINGERING { + entries.push(entry(®istry)); + } + let _newest = entry(®istry); + assert!(entries + .iter() + .all(|(tx, _)| *tx.borrow() == Teardown::Active)); + } + + #[test] + fn dropping_the_registry_cancels_lingerers_only() { + let registry = CableLingerRegistry::new(); + let (lingering, g1) = entry(®istry); + let (active, g2) = entry(®istry); + lingering.send_replace(Teardown::Linger); + + let clone = registry.clone(); + drop(registry); + assert_eq!( + *lingering.borrow(), + Teardown::Linger, + "a clone keeps it alive" + ); + assert!(g1.is_live()); + drop(clone); + assert_eq!(*lingering.borrow(), Teardown::Cancel); + assert_eq!(*active.borrow(), Teardown::Active); + assert!(!g1.is_live()); + assert!(!g2.is_live()); + } + + #[test] + fn linger_params_require_opt_in_and_eligibility() { + let registry = CableLingerRegistry::new(); + let config = CableLingerConfig::new(registry.clone()); + let (tx, _rx) = watch::channel(Teardown::Active); + let tx = Arc::new(tx); + + assert!(LingerParams::new(None, true, &tx).is_none()); + assert!(LingerParams::new(Some(&config), false, &tx).is_none()); + let params = LingerParams::new(Some(&config), true, &tx).expect("eligible"); + assert_eq!(params.linger_duration, CableLingerConfig::DEFAULT_DURATION); + tx.send_replace(Teardown::Linger); + assert_eq!(registry.lingering_count(), 1); + } + + #[test] + fn linger_duration_is_clamped_to_the_hard_cap() { + let mut config = CableLingerConfig::new(CableLingerRegistry::new()); + config.linger_duration = CableLingerConfig::HARD_CAP * 2; + let (tx, _rx) = watch::channel(Teardown::Active); + let params = LingerParams::new(Some(&config), true, &Arc::new(tx)).expect("eligible"); + assert_eq!(params.linger_duration, CableLingerConfig::HARD_CAP); + } +} diff --git a/libwebauthn/src/transport/cable/mod.rs b/libwebauthn/src/transport/cable/mod.rs index 5258344e..3b916d39 100644 --- a/libwebauthn/src/transport/cable/mod.rs +++ b/libwebauthn/src/transport/cable/mod.rs @@ -4,6 +4,7 @@ mod crypto; mod data_channel; mod digit_encode; mod l2cap; +mod linger; mod protocol; pub mod advertisement; @@ -15,7 +16,9 @@ pub mod qr_code_device; pub mod tunnel; use super::Transport; +pub use channel::CableClose; pub use digit_encode::digit_encode; +pub use linger::{CableLingerConfig, CableLingerRegistry}; /// Checks if the Cable/Hybrid transport is available on the system. /// Cable depends on a Bluetooth adapter for BLE advertisement discovery. diff --git a/libwebauthn/src/transport/cable/protocol.rs b/libwebauthn/src/transport/cable/protocol.rs index 73f68bb8..a7b40d3b 100644 --- a/libwebauthn/src/transport/cable/protocol.rs +++ b/libwebauthn/src/transport/cable/protocol.rs @@ -2,6 +2,7 @@ //! hybrid transport. Runs over any [`CableDataChannel`]. use std::collections::BTreeMap; use std::sync::Arc; +use std::time::Duration; use hmac::{Hmac, Mac}; use p256::{ecdh, NonZeroScalar}; @@ -20,14 +21,27 @@ 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::channel::ConnectionState; +use crate::transport::cable::connection_stages::{ + next_teardown, TunnelConnectionInput, UxUpdateSender, +}; use crate::transport::cable::error::CableError; use crate::transport::cable::known_devices::CableKnownDeviceId; +use crate::transport::cable::linger::{CableLingerConfig, LingerParams, Teardown}; const P256_X962_LENGTH: usize = 65; const MAX_CBOR_SIZE: usize = 1024 * 1024; const PADDING_GRANULARITY: usize = 32; +/// Bounds every outbound send, so a dead socket cannot stall teardown. +const SEND_TIMEOUT: Duration = Duration::from_secs(5); +/// Poll granularity of the linger receive, so the deadline and teardown are re-checked. +const LINGER_RECV_POLL: Duration = Duration::from_secs(30); +/// Bounds the processing of one linger frame, including the caller's store write. +const STORE_WRITE_TIMEOUT: Duration = Duration::from_secs(5); +/// Consecutive undecryptable frames before a lingering connection gives up. +const DECRYPT_FAILURE_BUDGET: u32 = 3; + const CABLE_PROLOGUE_STATE_ASSISTED: &[u8] = &[0u8]; const CABLE_PROLOGUE_QR_INITIATED: &[u8] = &[1u8]; @@ -46,9 +60,6 @@ impl CableTunnelMessage { } pub fn from_slice(slice: &[u8]) -> Result { let (type_byte, payload) = slice.split_first().ok_or(CableError::InvalidFraming)?; - if payload.is_empty() { - return Err(CableError::InvalidFraming); - } let message_type = match *type_byte { 0 => CableTunnelMessageType::Shutdown, @@ -59,6 +70,11 @@ impl CableTunnelMessage { } }; + // Shutdown is the type byte alone. Ctap and Update must carry a payload. + if payload.is_empty() && message_type != CableTunnelMessageType::Shutdown { + return Err(CableError::InvalidFraming); + } + Ok(Self { message_type, payload: ByteBuf::from(payload.to_vec()), @@ -107,7 +123,7 @@ pub(crate) struct CableLinkingInfo { } #[repr(u8)] -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] enum CableTunnelMessageType { Shutdown = 0, Ctap = 1, @@ -275,28 +291,66 @@ pub(crate) async fn do_handshake( /// Returns `Ok(())` on a clean close and `Err(_)` on any fault that leaves /// the encrypted channel unusable; callers surface `Err(_)` via `send_error`. -pub(crate) async fn connection(mut input: TunnelConnectionInput) -> Result<(), CableError> { - 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 Err(e); - } - }, - Ok(None) => { - error!("Connection closed before initial message was received"); - return Err(CableError::ConnectionLost); - } - Err(e) => { - error!(?e, "Failed to read initial message"); - return Err(e); +pub(crate) async fn connection( + mut input: TunnelConnectionInput, + ux_sender: &dyn UxUpdateSender, +) -> Result<(), CableError> { + // The secure channel exists, so a graceful teardown before the initial + // message still gets a courtesy Shutdown. + let get_info_response_serialized: Vec = loop { + tokio::select! { + biased; + intent = next_teardown(&mut input.teardown_rx) => match intent { + Teardown::Close | Teardown::Linger => { + send_shutdown_bounded(&mut *input.data_channel, &mut input.noise_state).await; + return Ok(()); + } + Teardown::Cancel => return Ok(()), + Teardown::Active => continue, + }, + result = input.data_channel.recv() => match result { + Ok(Some(message)) => { + match connection_recv_initial(message, &mut input.noise_state).await { + Ok(initial) => break initial, + Err(e) => { + error!(?e, "Failed to process initial message"); + return Err(e); + } + } + } + Ok(None) => { + error!("Connection closed before initial message was received"); + return Err(CableError::ConnectionLost); + } + Err(e) => { + error!(?e, "Failed to read initial message"); + return Err(e); + } + }, } }; debug!(?get_info_response_serialized, "Received initial message"); loop { tokio::select! { + biased; + intent = next_teardown(&mut input.teardown_rx) => match intent { + Teardown::Close => { + debug!("Channel close requested, sending Shutdown control frame"); + send_shutdown_bounded(&mut *input.data_channel, &mut input.noise_state).await; + return Ok(()); + } + Teardown::Linger => { + debug!("Channel linger requested, sending Shutdown control frame"); + send_shutdown_bounded(&mut *input.data_channel, &mut input.noise_state).await; + break; + } + Teardown::Cancel => { + debug!("Channel cancelled, dropping the connection"); + return Ok(()); + } + Teardown::Active => {} + }, result = input.data_channel.recv() => { match result { Ok(Some(message)) => { @@ -343,21 +397,166 @@ pub(crate) async fn connection(mut input: TunnelConnectionInput) -> Result<(), C } _ => { debug!(?request.command, "Sending CBOR request"); - if let Err(e) = connection_send( + let send = connection_send( request, &mut *input.data_channel, &mut input.noise_state, - ) - .await - { - error!(?e, "Fatal error sending CBOR request"); - return Err(e); + ); + match tokio::time::timeout(SEND_TIMEOUT, send).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + error!(?e, "Fatal error sending CBOR request"); + return Err(e); + } + Err(_) => { + error!("Timed out sending CBOR request"); + return Err(CableError::Timeout); + } } } } } }; } + + // Only a QR-initiated connection with a store can use a linking update. + let eligible = matches!( + input.connection_type, + CableTunnelConnectionType::QrCode { .. } + ) && input.known_device_store.is_some(); + match input.linger.take() { + Some(params) if eligible && params.guard.is_live() => { + linger(input, params, ux_sender).await + } + _ => {} + } + Ok(()) +} + +/// Outcome of one frame received while lingering. +enum LingerStep { + Keep, + PeerClosed, +} + +/// Keeps receiving after Shutdown to capture a late linking update. Detached +/// from the channel, so every await is bounded and the whole phase sits under +/// an absolute ceiling of [`CableLingerConfig::HARD_CAP`]. +async fn linger( + mut input: TunnelConnectionInput, + params: LingerParams, + ux_sender: &dyn UxUpdateSender, +) { + ux_sender + .set_connection_state(ConnectionState::Lingering) + .await; + debug!(linger_duration = ?params.linger_duration, "Lingering for a late linking update"); + + let now = tokio::time::Instant::now(); + let deadline = now + params.linger_duration; + let hard_cap = now + CableLingerConfig::HARD_CAP; + let mut decrypt_failures = 0u32; + + let run = async { + loop { + tokio::select! { + biased; + _ = next_teardown(&mut input.teardown_rx) => { + debug!("Linger cancelled"); + break; + } + _ = tokio::time::sleep_until(deadline) => { + debug!("Linger window elapsed"); + break; + } + received = tokio::time::timeout(LINGER_RECV_POLL, input.data_channel.recv()) => { + let frame = match received { + Err(_elapsed) => continue, + Ok(Ok(Some(frame))) => frame, + Ok(Ok(None)) | Ok(Err(_)) => { + debug!("Peer closed the connection while lingering"); + break; + } + }; + let step = linger_recv( + &input.connection_type, + &input.tunnel_domain, + &input.known_device_store, + frame, + &mut input.noise_state, + ); + match tokio::time::timeout(STORE_WRITE_TIMEOUT, step).await { + Ok(Ok(LingerStep::Keep)) => decrypt_failures = 0, + Ok(Ok(LingerStep::PeerClosed)) => break, + // A desynced peer fails every following frame. Anything + // else that decrypts is merely ignored. + Ok(Err(CableError::EncryptionFailed)) => { + decrypt_failures += 1; + warn!(decrypt_failures, "Undecryptable frame while lingering"); + if decrypt_failures >= DECRYPT_FAILURE_BUDGET { + break; + } + } + Ok(Err(e)) => debug!(?e, "Ignoring undecodable frame while lingering"), + Err(_elapsed) => { + warn!("Timed out processing a frame while lingering"); + break; + } + } + } + } + } + }; + if tokio::time::timeout_at(hard_cap, run).await.is_err() { + warn!("Linger hit the hard cap"); + } + // The registry guard drops with `params` here, deregistering the connection. + drop(params); +} + +/// Processes one frame received while lingering. Only a linking update has +/// any effect. Nothing is ever forwarded to the CBOR receiver. +async fn linger_recv( + connection_type: &CableTunnelConnectionType, + tunnel_domain: &str, + known_device_store: &Option>, + encrypted_frame: Vec, + noise_state: &mut TunnelNoiseState, +) -> Result { + let decrypted_frame = decrypt_frame(encrypted_frame, noise_state).await?; + let cable_message = CableTunnelMessage::from_slice(&decrypted_frame)?; + match cable_message.message_type { + CableTunnelMessageType::Shutdown => Ok(LingerStep::PeerClosed), + CableTunnelMessageType::Ctap => { + debug!("Ignoring CTAP frame while lingering"); + Ok(LingerStep::Keep) + } + CableTunnelMessageType::Update => { + handle_update_message( + connection_type, + tunnel_domain, + known_device_store, + &cable_message.payload, + &noise_state.handshake_hash, + ) + .await; + Ok(LingerStep::Keep) + } + } +} + +/// Best-effort Shutdown on a graceful teardown. A failure or timeout is +/// logged and otherwise ignored, since the connection is going away anyway. +async fn send_shutdown_bounded( + data_channel: &mut dyn CableDataChannel, + noise_state: &mut TunnelNoiseState, +) { + let send = connection_send_shutdown(data_channel, noise_state); + match tokio::time::timeout(SEND_TIMEOUT, send).await { + Ok(Ok(())) => {} + Ok(Err(e)) => warn!(?e, "Failed to send Shutdown control frame"), + Err(_) => warn!("Timed out sending Shutdown control frame"), + } } async fn connection_send( @@ -378,16 +577,47 @@ async fn connection_send( } 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; + send_tunnel_frame( + CableTunnelMessageType::Ctap, + &cbor_request, + data_channel, + noise_state, + ) + .await +} + +/// Sends an empty `Shutdown` control frame over the encrypted channel. +async fn connection_send_shutdown( + data_channel: &mut dyn CableDataChannel, + noise_state: &mut TunnelNoiseState, +) -> Result<(), CableError> { + debug!("Sending Shutdown control frame"); + send_tunnel_frame( + CableTunnelMessageType::Shutdown, + &[], + data_channel, + noise_state, + ) + .await +} - let mut padded_cbor_request = cbor_request.clone(); - padded_cbor_request.resize(padded_len, 0u8); - if let Some(last) = padded_cbor_request.last_mut() { +/// Pads `payload`, wraps it in a `CableTunnelMessage`, encrypts it, and sends it. +async fn send_tunnel_frame( + message_type: CableTunnelMessageType, + payload: &[u8], + data_channel: &mut dyn CableDataChannel, + noise_state: &mut TunnelNoiseState, +) -> Result<(), CableError> { + let extra_bytes = PADDING_GRANULARITY - (payload.len() % PADDING_GRANULARITY); + let padded_len = payload.len() + extra_bytes; + + let mut padded_payload = payload.to_vec(); + padded_payload.resize(padded_len, 0u8); + if let Some(last) = padded_payload.last_mut() { *last = (extra_bytes - 1) as u8; } - let frame = CableTunnelMessage::new(CableTunnelMessageType::Ctap, &padded_cbor_request); + let frame = CableTunnelMessage::new(message_type, &padded_payload); let frame_serialized = frame.to_vec(); trace!(?frame_serialized); @@ -588,46 +818,69 @@ async fn connection_recv( Ok(RecvOutcome::Continue) } CableTunnelMessageType::Update => { - // Malformed or unsigned update: log, drop the update, keep the channel. - let maybe_update_message = match connection_recv_update(&cable_message.payload).await { - Ok(m) => m, - Err(e) => { - warn!(?e, "Malformed update message; ignoring"); - return Ok(RecvOutcome::Continue); - } - }; + let update = handle_update_message( + connection_type, + tunnel_domain, + known_device_store, + &cable_message.payload, + &noise_state.handshake_hash, + ); + if tokio::time::timeout(STORE_WRITE_TIMEOUT, update) + .await + .is_err() + { + warn!("Timed out storing a linking update; ignoring it"); + } + Ok(RecvOutcome::Continue) + } + } +} - let Some(linking_info) = maybe_update_message else { - warn!("Ignoring update message without linking info"); - return Ok(RecvOutcome::Continue); - }; +/// Applies a linking update to the store. Malformed, unsigned, non-QR or +/// store-less updates are logged and dropped without affecting the channel. +async fn handle_update_message( + connection_type: &CableTunnelConnectionType, + tunnel_domain: &str, + known_device_store: &Option>, + payload: &[u8], + handshake_hash: &[u8], +) { + let maybe_update_message = match connection_recv_update(payload).await { + Ok(m) => m, + Err(e) => { + warn!(?e, "Malformed update message; ignoring"); + return; + } + }; - let CableTunnelConnectionType::QrCode { private_key, .. } = connection_type else { - warn!("Ignoring update message for non-QR code connection"); - return Ok(RecvOutcome::Continue); - }; + let Some(linking_info) = maybe_update_message else { + warn!("Ignoring update message without linking info"); + return; + }; - debug!("Received update message with linking info"); - trace!(?linking_info); - - match known_device_store { - Some(store) => { - apply_linking_update( - store, - private_key, - tunnel_domain, - &linking_info, - &noise_state.handshake_hash, - ) - .await; - } - None => { - warn!("Ignoring update message without a device store"); - } - }; - Ok(RecvOutcome::Continue) + let CableTunnelConnectionType::QrCode { private_key, .. } = connection_type else { + warn!("Ignoring update message for non-QR code connection"); + return; + }; + + debug!("Received update message with linking info"); + trace!(?linking_info); + + match known_device_store { + Some(store) => { + apply_linking_update( + store, + private_key, + tunnel_domain, + &linking_info, + handshake_hash, + ) + .await; } - } + None => { + warn!("Ignoring update message without a device store"); + } + }; } /// Stores the update only on a valid signature; invalid updates are dropped without evicting. @@ -773,6 +1026,37 @@ mod tests { ); } + #[test] + fn from_slice_accepts_type_only_shutdown() { + let message = CableTunnelMessage::from_slice(&[0]).unwrap(); + assert_eq!(message.message_type, CableTunnelMessageType::Shutdown); + assert!(message.payload.is_empty()); + } + + #[test] + fn from_slice_rejects_empty_ctap_and_update() { + assert!(matches!( + CableTunnelMessage::from_slice(&[1]), + Err(CableError::InvalidFraming) + )); + assert!(matches!( + CableTunnelMessage::from_slice(&[2]), + Err(CableError::InvalidFraming) + )); + } + + #[test] + fn from_slice_rejects_empty_frame_and_unknown_type() { + assert!(matches!( + CableTunnelMessage::from_slice(&[]), + Err(CableError::InvalidFraming) + )); + assert!(matches!( + CableTunnelMessage::from_slice(&[3, 0]), + Err(CableError::InvalidFraming) + )); + } + #[test] fn strip_frame_padding_rejects_empty() { let result = strip_frame_padding(Vec::new()); @@ -794,4 +1078,813 @@ mod tests { let stripped = strip_frame_padding(frame).unwrap(); assert_eq!(stripped, vec![0xAA, 0xBB, 0xCC, 0xDD]); } + + use serde_indexed::SerializeIndexed; + use tokio::sync::{mpsc, watch}; + + use crate::transport::cable::channel::{CableUxUpdate, ConnectionState}; + use crate::transport::cable::linger::CableLingerRegistry; + + const DEFAULT_LINGER: Duration = CableLingerConfig::DEFAULT_DURATION; + const HARD_CAP: Duration = CableLingerConfig::HARD_CAP; + + /// In-memory data channel: records outbound frames and replays queued inbound ones. + struct TestDataChannel { + inbound: mpsc::UnboundedReceiver>, + outbound: mpsc::UnboundedSender>, + } + + #[async_trait] + impl CableDataChannel for TestDataChannel { + async fn send(&mut self, message: &[u8]) -> Result<(), CableError> { + let _ = self.outbound.send(message.to_vec()); + Ok(()) + } + + async fn recv(&mut self) -> Result>, CableError> { + Ok(self.inbound.recv().await) + } + } + + /// A data channel whose sends never complete, like a stalled socket. + struct WedgedSendChannel { + inbound: mpsc::UnboundedReceiver>, + } + + #[async_trait] + impl CableDataChannel for WedgedSendChannel { + async fn send(&mut self, _message: &[u8]) -> Result<(), CableError> { + std::future::pending().await + } + + async fn recv(&mut self) -> Result>, CableError> { + Ok(self.inbound.recv().await) + } + } + + /// Publishes connection states on a watch so tests can observe phases. + struct TestUxSender { + state_tx: watch::Sender, + } + + #[async_trait] + impl UxUpdateSender for TestUxSender { + async fn send_update(&self, _update: CableUxUpdate) {} + async fn send_error(&self, _error: CableError) { + let _ = self.state_tx.send(ConnectionState::Terminated); + } + async fn set_connection_state(&self, state: ConnectionState) { + let _ = self.state_tx.send(state); + } + } + + /// Two Noise transport states that can encrypt/decrypt to each other. + fn paired_transport_states() -> (TransportState, TransportState) { + let mut initiator = Builder::new("Noise_NN_P256_AESGCM_SHA256".parse().unwrap()) + .build_initiator() + .unwrap(); + let mut responder = Builder::new("Noise_NN_P256_AESGCM_SHA256".parse().unwrap()) + .build_responder() + .unwrap(); + let mut a = [0u8; 1024]; + let mut b = [0u8; 1024]; + let n = initiator.write_message(&[], &mut a).unwrap(); + responder.read_message(&a[..n], &mut b).unwrap(); + let n = responder.write_message(&[], &mut a).unwrap(); + initiator.read_message(&a[..n], &mut b).unwrap(); + ( + initiator.into_transport_mode().unwrap(), + responder.into_transport_mode().unwrap(), + ) + } + + fn pad(mut payload: Vec) -> Vec { + let extra = PADDING_GRANULARITY - (payload.len() % PADDING_GRANULARITY); + let new_len = payload.len() + extra; + payload.resize(new_len, 0u8); + *payload.last_mut().unwrap() = (extra - 1) as u8; + payload + } + + fn encrypt(state: &mut TransportState, plaintext: &[u8]) -> Vec { + let mut out = vec![0u8; plaintext.len() + 64]; + let n = state.write_message(plaintext, &mut out).unwrap(); + out.truncate(n); + out + } + + fn decrypt(state: &mut TransportState, ciphertext: &[u8]) -> Vec { + let mut out = vec![0u8; ciphertext.len() + 64]; + let n = state.read_message(ciphertext, &mut out).unwrap(); + out.truncate(n); + out + } + + #[derive(SerializeIndexed)] + struct TestInitialMessage { + #[serde(index = 0x01)] + info: ByteBuf, + } + + /// Encrypted initial post-handshake message carrying a minimal GetInfo. + fn encrypted_initial_message(responder: &mut TransportState) -> Vec { + let get_info = Ctap2GetInfoResponse { + versions: vec!["FIDO_2_0".to_string()], + aaguid: ByteBuf::from(vec![0u8; 16]), + ..Default::default() + }; + let initial = TestInitialMessage { + info: ByteBuf::from(cbor::to_vec(&get_info).unwrap()), + }; + encrypt(responder, &pad(cbor::to_vec(&initial).unwrap())) + } + + fn qr_connection_type() -> CableTunnelConnectionType { + qr_connection_type_with(NonZeroScalar::random(&mut OsRng)) + } + + fn qr_connection_type_with(private_key: NonZeroScalar) -> CableTunnelConnectionType { + CableTunnelConnectionType::QrCode { + routing_id: "000000".to_string(), + tunnel_id: "00000000000000000000000000000000".to_string(), + private_key, + } + } + + fn known_device_connection_type() -> CableTunnelConnectionType { + CableTunnelConnectionType::KnownDevice { + contact_id: "contact".to_string(), + authenticator_public_key: vec![0u8; 65], + client_payload: ClientPayload { + link_id: ByteBuf::from(vec![0u8; 8]), + client_nonce: ByteBuf::from(vec![0u8; 16]), + hint: crate::transport::cable::known_devices::ClientPayloadHint::GetAssertion, + }, + } + } + + /// A linking update signed by a fresh authenticator key for the QR + /// private key of the connection. Returns the plaintext tunnel frame and + /// the known device id the store will see. + fn signed_update_payload( + qr_private_key: &NonZeroScalar, + handshake_hash: &[u8], + ) -> (Vec, CableKnownDeviceId) { + let authenticator_secret = SecretKey::random(&mut OsRng); + let authenticator_public_key = authenticator_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + let shared_secret = ecdh::diffie_hellman( + qr_private_key, + authenticator_secret.public_key().as_affine(), + ) + .raw_secret_bytes() + .to_vec(); + let mut hmac = Hmac::::new_from_slice(&shared_secret).unwrap(); + hmac.update(handshake_hash); + let signature = hmac.finalize().into_bytes().to_vec(); + + let mut info = BTreeMap::new(); + info.insert(Value::Integer(1), Value::Bytes(vec![0u8; 4])); + info.insert(Value::Integer(2), Value::Bytes(vec![0u8; 8])); + info.insert(Value::Integer(3), Value::Bytes(vec![0u8; 32])); + info.insert( + Value::Integer(4), + Value::Bytes(authenticator_public_key.clone()), + ); + info.insert(Value::Integer(5), Value::Text("alice's phone".to_string())); + info.insert(Value::Integer(6), Value::Bytes(signature)); + let mut update = BTreeMap::new(); + update.insert(Value::Integer(1), Value::Map(info)); + + let mut payload = vec![CableTunnelMessageType::Update as u8]; + payload.extend(serde_cbor::to_vec(&Value::Map(update)).unwrap()); + (payload, hex::encode(&authenticator_public_key)) + } + + /// Reports every stored device on a channel so tests can await the write. + #[derive(Debug)] + struct NotifyingStore { + puts: mpsc::UnboundedSender, + } + + #[async_trait] + impl CableKnownDeviceInfoStore for NotifyingStore { + async fn put_known_device( + &self, + device_id: &CableKnownDeviceId, + _device: &CableKnownDeviceInfo, + ) { + let _ = self.puts.send(device_id.clone()); + } + async fn delete_known_device(&self, _device_id: &CableKnownDeviceId) {} + } + + /// A store whose writes never complete. + #[derive(Debug)] + struct WedgedStore; + + #[async_trait] + impl CableKnownDeviceInfoStore for WedgedStore { + async fn put_known_device( + &self, + _device_id: &CableKnownDeviceId, + _device: &CableKnownDeviceInfo, + ) { + std::future::pending::<()>().await; + } + async fn delete_known_device(&self, _device_id: &CableKnownDeviceId) {} + } + + /// Decrypts an outbound frame and returns its tunnel message type byte. + fn outbound_message_type(frame: &[u8], responder: &mut TransportState) -> u8 { + let stripped = strip_frame_padding(decrypt(responder, frame)).unwrap(); + *stripped.first().unwrap() + } + + /// The peer side of a post-handshake connection plus every caller-side handle. + struct Harness { + responder: TransportState, + inbound_tx: mpsc::UnboundedSender>, + outbound_rx: mpsc::UnboundedReceiver>, + cbor_tx_send: mpsc::Sender, + cbor_rx_recv: mpsc::Receiver, + teardown_tx: Arc>, + state_rx: watch::Receiver, + input: Option, + ux_sender: Option, + } + + impl Harness { + fn new(connection_type: CableTunnelConnectionType) -> Self { + let (initiator, responder) = paired_transport_states(); + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel::>(); + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::>(); + let (cbor_tx_send, cbor_tx_recv) = mpsc::channel::(4); + let (cbor_rx_send, cbor_rx_recv) = mpsc::channel::(4); + let (teardown_tx, teardown_rx) = watch::channel(Teardown::Active); + let (state_tx, state_rx) = watch::channel(ConnectionState::Connected); + let input = TunnelConnectionInput { + connection_type, + tunnel_domain: "cable.example.com".to_string(), + known_device_store: None, + data_channel: Box::new(TestDataChannel { + inbound: inbound_rx, + outbound: outbound_tx, + }), + noise_state: TunnelNoiseState { + transport_state: initiator, + handshake_hash: vec![0u8; 32], + }, + cbor_tx_recv, + cbor_rx_send, + teardown_rx, + linger: None, + }; + Self { + responder, + inbound_tx, + outbound_rx, + cbor_tx_send, + cbor_rx_recv, + teardown_tx: Arc::new(teardown_tx), + state_rx, + input: Some(input), + ux_sender: Some(TestUxSender { state_tx }), + } + } + + fn qr() -> Self { + Self::new(qr_connection_type()) + } + + fn input_mut(&mut self) -> &mut TunnelConnectionInput { + self.input.as_mut().expect("not spawned yet") + } + + fn with_store(mut self, store: Arc) -> Self { + self.input_mut().known_device_store = Some(store); + self + } + + /// Replaces the data channel with one whose sends stall forever. + fn with_wedged_send(mut self) -> Self { + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel::>(); + self.inbound_tx = inbound_tx; + self.input_mut().data_channel = Box::new(WedgedSendChannel { + inbound: inbound_rx, + }); + self + } + + /// Registers the connection with `registry` as an eligible lingerer. + fn with_linger( + mut self, + registry: &CableLingerRegistry, + linger_duration: Duration, + ) -> Self { + let guard = registry.register(self.teardown_tx.clone()); + self.input_mut().linger = Some(LingerParams { + linger_duration, + guard, + }); + self + } + + async fn wait_for_state(&mut self, state: ConnectionState) { + self.state_rx + .wait_for(|current| *current == state) + .await + .expect("state sender alive"); + } + + /// Round-trips a cached GetInfo, proving the loop has consumed the + /// initial message and is in its active phase. + async fn await_active(&mut self) { + self.cbor_tx_send + .send(CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo)) + .await + .unwrap(); + self.cbor_rx_recv + .recv() + .await + .expect("cached GetInfo response"); + } + + /// Sends the Linger intent from the active phase and waits for the + /// Shutdown that precedes the linger. + async fn start_linger(&mut self) { + self.await_active().await; + self.teardown(Teardown::Linger); + assert_eq!( + self.next_outbound_type().await, + CableTunnelMessageType::Shutdown as u8 + ); + } + + /// Queues the peer's initial message, as sent right after the handshake. + fn send_initial_message(&mut self) { + let frame = encrypted_initial_message(&mut self.responder); + self.inbound_tx.send(frame).unwrap(); + } + + fn send_peer_frame(&mut self, plaintext: Vec) { + let frame = encrypt(&mut self.responder, &pad(plaintext)); + self.inbound_tx.send(frame).unwrap(); + } + + /// Runs the connection loop on its own task. + fn spawn(&mut self) -> tokio::task::JoinHandle> { + let input = self.input.take().expect("spawned once"); + let ux_sender = self.ux_sender.take().expect("spawned once"); + tokio::spawn(async move { connection(input, &ux_sender).await }) + } + + fn teardown(&self, intent: Teardown) { + self.teardown_tx.send_replace(intent); + } + + async fn next_outbound_type(&mut self) -> u8 { + let frame = self.outbound_rx.recv().await.expect("an outbound frame"); + outbound_message_type(&frame, &mut self.responder) + } + } + + #[tokio::test] + async fn connection_sends_shutdown_on_close() { + let mut h = Harness::qr(); + h.send_initial_message(); + let handle = h.spawn(); + + h.await_active().await; + h.teardown(Teardown::Close); + + assert_eq!( + h.next_outbound_type().await, + CableTunnelMessageType::Shutdown as u8 + ); + assert!(handle.await.unwrap().is_ok()); + assert!(h.outbound_rx.try_recv().is_err(), "exactly one Shutdown"); + } + + #[tokio::test] + async fn cancel_sends_nothing() { + let mut h = Harness::qr(); + h.send_initial_message(); + let handle = h.spawn(); + + h.await_active().await; + h.teardown(Teardown::Cancel); + + assert!(handle.await.unwrap().is_ok()); + assert!(h.outbound_rx.try_recv().is_err(), "no Shutdown on cancel"); + } + + #[tokio::test] + async fn close_before_initial_message_sends_shutdown() { + let mut h = Harness::qr(); + let handle = h.spawn(); + + // The peer never sends its initial message; the loop must still + // honour the close promptly and say goodbye. + h.teardown(Teardown::Close); + + assert_eq!( + h.next_outbound_type().await, + CableTunnelMessageType::Shutdown as u8 + ); + assert!(handle.await.unwrap().is_ok()); + } + + #[tokio::test] + async fn cancel_before_initial_message_terminates_silently() { + let mut h = Harness::qr(); + let handle = h.spawn(); + + h.teardown(Teardown::Cancel); + + assert!(handle.await.unwrap().is_ok()); + assert!(h.outbound_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn every_sender_gone_counts_as_cancel() { + let mut h = Harness::qr(); + h.send_initial_message(); + let handle = h.spawn(); + + drop(h.teardown_tx); + + assert!(handle.await.unwrap().is_ok()); + assert!(h.outbound_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn peer_shutdown_ends_connection_cleanly() { + let mut h = Harness::qr(); + h.send_initial_message(); + // A type-only Shutdown frame from the peer, padded like any other frame. + h.send_peer_frame(vec![CableTunnelMessageType::Shutdown as u8]); + let handle = h.spawn(); + + assert!(handle.await.unwrap().is_ok()); + assert!( + h.outbound_rx.try_recv().is_err(), + "no frame is sent in reply" + ); + } + + #[tokio::test] + async fn ctap_request_is_forwarded_and_response_delivered() { + let mut h = Harness::qr(); + h.send_initial_message(); + let handle = h.spawn(); + + h.cbor_tx_send + .send(CborRequest::new(Ctap2CommandCode::AuthenticatorClientPin)) + .await + .unwrap(); + assert_eq!( + h.next_outbound_type().await, + CableTunnelMessageType::Ctap as u8 + ); + + // A CTAP response frame: [Ctap type byte][CTAP status OK]. + h.send_peer_frame(vec![CableTunnelMessageType::Ctap as u8, 0x00]); + h.cbor_rx_recv.recv().await.expect("a CTAP response"); + + h.teardown(Teardown::Cancel); + assert!(handle.await.unwrap().is_ok()); + } + + #[tokio::test(start_paused = true)] + async fn linger_captures_a_late_linking_update() { + let qr_private_key = NonZeroScalar::random(&mut OsRng); + let (puts_tx, mut puts_rx) = mpsc::unbounded_channel(); + let registry = CableLingerRegistry::new(); + let mut h = Harness::new(qr_connection_type_with(qr_private_key)) + .with_store(Arc::new(NotifyingStore { puts: puts_tx })) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + + let (payload, device_id) = signed_update_payload(&qr_private_key, &[0u8; 32]); + h.send_peer_frame(payload); + assert_eq!(puts_rx.recv().await.unwrap(), device_id); + assert!( + h.cbor_rx_recv.try_recv().is_err(), + "nothing reaches the CBOR receiver" + ); + + h.teardown(Teardown::Cancel); + assert!(handle.await.unwrap().is_ok()); + assert_eq!(registry.lingering_count(), 0); + } + + #[tokio::test(start_paused = true)] + async fn linger_window_elapses() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, Duration::from_secs(10)); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + let started = tokio::time::Instant::now(); + + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), Duration::from_secs(10)); + assert_eq!(registry.lingering_count(), 0); + assert!(h.outbound_rx.try_recv().is_err(), "no second Shutdown"); + } + + #[tokio::test(start_paused = true)] + async fn hard_cap_bounds_an_overlong_window() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, HARD_CAP * 2); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + let started = tokio::time::Instant::now(); + + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), HARD_CAP); + } + + #[tokio::test(start_paused = true)] + async fn close_lingering_evicts_a_lingerer() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + assert_eq!(registry.lingering_count(), 1); + + let started = tokio::time::Instant::now(); + assert_eq!(registry.close_lingering(), 1); + assert!(handle.await.unwrap().is_ok()); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(registry.lingering_count(), 0); + } + + #[tokio::test(start_paused = true)] + async fn wedged_store_write_is_preempted() { + let qr_private_key = NonZeroScalar::random(&mut OsRng); + let registry = CableLingerRegistry::new(); + let mut h = Harness::new(qr_connection_type_with(qr_private_key)) + .with_store(Arc::new(WedgedStore)) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + let started = tokio::time::Instant::now(); + + let (payload, _) = signed_update_payload(&qr_private_key, &[0u8; 32]); + h.send_peer_frame(payload); + + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), STORE_WRITE_TIMEOUT); + } + + #[tokio::test(start_paused = true)] + async fn decrypt_failure_budget_ends_the_linger() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + + for _ in 0..DECRYPT_FAILURE_BUDGET { + h.inbound_tx.send(vec![0xFFu8; 48]).unwrap(); + } + let started = tokio::time::Instant::now(); + assert!(handle.await.unwrap().is_ok()); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[tokio::test(start_paused = true)] + async fn peer_shutdown_ends_the_linger() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + + h.send_peer_frame(vec![CableTunnelMessageType::Shutdown as u8]); + let started = tokio::time::Instant::now(); + assert!(handle.await.unwrap().is_ok()); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[tokio::test(start_paused = true)] + async fn ctap_frames_are_ignored_while_lingering() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, Duration::from_secs(10)); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + + let started = tokio::time::Instant::now(); + h.send_peer_frame(vec![CableTunnelMessageType::Ctap as u8, 0x00]); + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), Duration::from_secs(10)); + assert!(h.cbor_rx_recv.try_recv().is_err()); + } + + #[tokio::test(start_paused = true)] + async fn linger_without_a_store_is_a_close() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr().with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + assert!(handle.await.unwrap().is_ok()); + assert_ne!(*h.state_rx.borrow(), ConnectionState::Lingering); + assert_eq!(registry.lingering_count(), 0); + } + + #[tokio::test(start_paused = true)] + async fn known_device_connection_never_lingers() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::new(known_device_connection_type()) + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + assert!(handle.await.unwrap().is_ok()); + assert_ne!(*h.state_rx.borrow(), ConnectionState::Lingering); + assert_eq!(registry.lingering_count(), 0); + } + + #[tokio::test] + async fn linger_before_the_initial_message_is_a_close() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, DEFAULT_LINGER); + let handle = h.spawn(); + + h.teardown(Teardown::Linger); + assert_eq!( + h.next_outbound_type().await, + CableTunnelMessageType::Shutdown as u8 + ); + assert!(handle.await.unwrap().is_ok()); + assert_ne!(*h.state_rx.borrow(), ConnectionState::Lingering); + assert_eq!(registry.lingering_count(), 0); + } + + #[tokio::test(start_paused = true)] + async fn wedged_store_write_in_the_active_phase_does_not_block_close() { + let qr_private_key = NonZeroScalar::random(&mut OsRng); + let mut h = + Harness::new(qr_connection_type_with(qr_private_key)).with_store(Arc::new(WedgedStore)); + h.send_initial_message(); + let handle = h.spawn(); + h.await_active().await; + + let (payload, _) = signed_update_payload(&qr_private_key, &[0u8; 32]); + h.send_peer_frame(payload); + tokio::task::yield_now().await; + let started = tokio::time::Instant::now(); + h.teardown(Teardown::Close); + + assert_eq!( + h.next_outbound_type().await, + CableTunnelMessageType::Shutdown as u8 + ); + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), STORE_WRITE_TIMEOUT); + } + + #[tokio::test(start_paused = true)] + async fn close_with_a_wedged_socket_terminates_at_send_timeout() { + let mut h = Harness::qr().with_wedged_send(); + h.send_initial_message(); + let handle = h.spawn(); + h.await_active().await; + + let started = tokio::time::Instant::now(); + h.teardown(Teardown::Close); + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), SEND_TIMEOUT); + } + + #[tokio::test(start_paused = true)] + async fn cbor_send_on_a_wedged_socket_fails_at_send_timeout() { + let mut h = Harness::qr().with_wedged_send(); + h.send_initial_message(); + let handle = h.spawn(); + h.await_active().await; + + let started = tokio::time::Instant::now(); + h.cbor_tx_send + .send(CborRequest::new(Ctap2CommandCode::AuthenticatorClientPin)) + .await + .unwrap(); + assert!(matches!(handle.await.unwrap(), Err(CableError::Timeout))); + assert_eq!(started.elapsed(), SEND_TIMEOUT); + } + + #[tokio::test(start_paused = true)] + async fn decrypt_failures_below_the_budget_keep_the_linger_alive() { + let qr_private_key = NonZeroScalar::random(&mut OsRng); + let (puts_tx, mut puts_rx) = mpsc::unbounded_channel(); + let registry = CableLingerRegistry::new(); + let mut h = Harness::new(qr_connection_type_with(qr_private_key)) + .with_store(Arc::new(NotifyingStore { puts: puts_tx })) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + + for _ in 0..DECRYPT_FAILURE_BUDGET - 1 { + h.inbound_tx.send(vec![0xFFu8; 48]).unwrap(); + } + // A good frame resets the count and is still applied. + let (payload, device_id) = signed_update_payload(&qr_private_key, &[0u8; 32]); + h.send_peer_frame(payload); + assert_eq!(puts_rx.recv().await.unwrap(), device_id); + assert_eq!(registry.lingering_count(), 1); + + for _ in 0..DECRYPT_FAILURE_BUDGET - 1 { + h.inbound_tx.send(vec![0xFFu8; 48]).unwrap(); + } + tokio::task::yield_now().await; + assert_eq!(registry.lingering_count(), 1, "still under the budget"); + + h.inbound_tx.send(vec![0xFFu8; 48]).unwrap(); + assert!(handle.await.unwrap().is_ok()); + } + + #[tokio::test(start_paused = true)] + async fn unknown_frame_types_are_ignored_while_lingering() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, Duration::from_secs(10)); + h.send_initial_message(); + let handle = h.spawn(); + + h.start_linger().await; + h.wait_for_state(ConnectionState::Lingering).await; + + let started = tokio::time::Instant::now(); + for _ in 0..DECRYPT_FAILURE_BUDGET + 1 { + // Decrypts fine, unknown type byte (e.g. a CTAP 2.3 JSON frame). + h.send_peer_frame(vec![3, 0x00]); + } + assert!(handle.await.unwrap().is_ok()); + assert_eq!(started.elapsed(), Duration::from_secs(10)); + } + + #[tokio::test(start_paused = true)] + async fn linger_with_a_dropped_registry_is_a_close() { + let registry = CableLingerRegistry::new(); + let mut h = Harness::qr() + .with_store(Arc::new(RecordingStore::default())) + .with_linger(®istry, DEFAULT_LINGER); + h.send_initial_message(); + let handle = h.spawn(); + h.await_active().await; + + drop(registry); + h.start_linger().await; + assert!(handle.await.unwrap().is_ok()); + assert_ne!(*h.state_rx.borrow(), ConnectionState::Lingering); + } } diff --git a/libwebauthn/src/transport/cable/qr_code_device.rs b/libwebauthn/src/transport/cable/qr_code_device.rs index 5f85e8a3..15d10b17 100644 --- a/libwebauthn/src/transport/cable/qr_code_device.rs +++ b/libwebauthn/src/transport/cable/qr_code_device.rs @@ -13,13 +13,14 @@ use serde_indexed::SerializeIndexed; use serde_repr::Serialize_repr; use tokio::sync::{broadcast, mpsc, watch}; use tokio::task; -use tracing::instrument; +use tracing::{debug, instrument}; use super::connection_stages::{ - connection_stage, handshake_stage, proximity_check_stage, ConnectionInput, HandshakeInput, - MpscUxUpdateSender, ProximityCheckInput, TunnelConnectionInput, UxUpdateSender, + connection_stage, handshake_stage, proximity_check_stage, until_teardown, ConnectionInput, + HandshakeInput, MpscUxUpdateSender, ProximityCheckInput, TunnelConnectionInput, UxUpdateSender, }; use super::known_devices::CableKnownDeviceInfoStore; +use super::linger::{LingerParams, Teardown}; use super::protocol; use super::tunnel::KNOWN_TUNNEL_DOMAINS; use super::{channel::CableChannel, channel::ConnectionState, Cable}; @@ -210,6 +211,11 @@ impl CableQrCodeDevice { Self::new(hint, false, None, transports) } + /// Only a state-assisted QR connection with a store can use a late linking update. + fn linger_eligible(&self) -> bool { + self.qr_code.state_assisted == Some(true) && self.store.is_some() + } + #[instrument(skip_all, err)] async fn connection( qr_device: &CableQrCodeDevice, @@ -249,6 +255,20 @@ impl<'d> Device<'d, Cable, CableChannel> for CableQrCodeDevice { let (cbor_rx_send, cbor_rx_recv) = mpsc::channel(16); let (connection_state_sender, connection_state_receiver) = watch::channel(ConnectionState::Connecting); + let (teardown_tx, teardown_rx) = watch::channel(Teardown::Active); + let teardown_tx = Arc::new(teardown_tx); + let mut teardown_rx_connect = teardown_rx.clone(); + + // A new connection supersedes any connection still lingering. + if let Some(config) = &settings.cable_linger { + config.registry.close_lingering(); + } + let linger = LingerParams::new( + settings.cable_linger.as_ref(), + self.linger_eligible(), + &teardown_tx, + ); + let linger_eligible = linger.is_some(); let ux_update_sender_clone = ux_update_sender.clone(); let qr_device = self.clone(); @@ -257,12 +277,21 @@ impl<'d> Device<'d, Cable, CableChannel> for CableQrCodeDevice { let ux_sender = MpscUxUpdateSender::new(ux_update_sender_clone.clone(), connection_state_sender); - let handshake_output = match Self::connection(&qr_device, &ux_sender).await { - Ok(handshake_output) => handshake_output, - Err(e) => { + let connecting = Self::connection(&qr_device, &ux_sender); + let handshake_output = match until_teardown(connecting, &mut teardown_rx_connect).await + { + Some(Ok(handshake_output)) => handshake_output, + Some(Err(e)) => { ux_sender.send_error(e).await; return; } + None => { + debug!("Hybrid connection torn down before the handshake completed"); + ux_sender + .set_connection_state(ConnectionState::Terminated) + .await; + return; + } }; let tunnel_input = TunnelConnectionInput::from_handshake_output( @@ -270,8 +299,10 @@ impl<'d> Device<'d, Cable, CableChannel> for CableQrCodeDevice { qr_device.store, cbor_tx_recv, cbor_rx_send, + teardown_rx, + linger, ); - match protocol::connection(tunnel_input).await { + match protocol::connection(tunnel_input, &ux_sender).await { Ok(()) => { ux_sender .set_connection_state(ConnectionState::Terminated) @@ -291,6 +322,8 @@ impl<'d> Device<'d, Cable, CableChannel> for CableQrCodeDevice { ux_update_sender, connection_state_receiver, persistent_token_store: settings.persistent_token_store, + teardown: teardown_tx, + linger_eligible, }) } @@ -312,6 +345,28 @@ mod tests { assert_send_sync::(); }; + #[test] + fn transient_qr_code_is_not_linger_eligible() { + let device = CableQrCodeDevice::new_transient( + QrCodeOperationHint::GetAssertionRequest, + CableTransports::CloudAssistedOnly, + ) + .unwrap(); + assert!(!device.linger_eligible()); + } + + #[test] + fn persistent_qr_code_is_linger_eligible() { + let store = Arc::new(super::super::known_devices::EphemeralDeviceInfoStore::new()); + let device = CableQrCodeDevice::new_persistent( + QrCodeOperationHint::GetAssertionRequest, + store, + CableTransports::CloudAssistedOnly, + ) + .unwrap(); + assert!(device.linger_eligible()); + } + #[test] fn qr_code_omits_key_6_for_cloud_assisted_only() { let device = CableQrCodeDevice::new_transient( diff --git a/libwebauthn/src/transport/cable/tunnel.rs b/libwebauthn/src/transport/cable/tunnel.rs index 528bcc66..a64284e8 100644 --- a/libwebauthn/src/transport/cable/tunnel.rs +++ b/libwebauthn/src/transport/cable/tunnel.rs @@ -3,8 +3,9 @@ use sha2::{Digest, Sha256}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::handshake::client::Request; use tokio_tungstenite::tungstenite::http::{header::LOCATION, StatusCode}; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::tungstenite::Error as TungsteniteError; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{connect_async_with_config, MaybeTlsStream, WebSocketStream}; use tracing::{debug, error, trace}; use tungstenite::client::IntoClientRequest; use url::Url; @@ -16,6 +17,16 @@ use crate::proto::ctap2::cbor; use crate::transport::cable::error::CableError; const MAX_TUNNEL_REDIRECTS: usize = 5; +/// Largest tunnel message accepted from the server. A Noise transport message +/// is at most 65535 bytes, so nothing larger could be decrypted anyway. +/// Everything above is rejected before it is buffered. +const MAX_WS_MESSAGE_SIZE: usize = 65535; + +fn websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_message_size(Some(MAX_WS_MESSAGE_SIZE)) + .max_frame_size(Some(MAX_WS_MESSAGE_SIZE)) +} fn ensure_rustls_crypto_provider() { use std::sync::Once; @@ -139,7 +150,8 @@ pub(crate) async fn connect( let request = build_tunnel_request(&connect_url, connection_type)?; trace!(?request); - let error = match connect_async(request).await { + let error = match connect_async_with_config(request, Some(websocket_config()), false).await + { Ok((ws_stream, response)) => { debug!(?response, "Connected to tunnel server"); if response.status() != StatusCode::SWITCHING_PROTOCOLS { @@ -188,9 +200,48 @@ mod tests { use super::*; use crate::transport::cable::known_devices::{ClientPayload, ClientPayloadHint}; use p256::NonZeroScalar; + use rand::rngs::OsRng; use serde_bytes::ByteBuf; + #[test] + fn websocket_config_bounds_message_and_frame_size() { + let config = websocket_config(); + assert_eq!(config.max_message_size, Some(MAX_WS_MESSAGE_SIZE)); + assert_eq!(config.max_frame_size, Some(MAX_WS_MESSAGE_SIZE)); + let default = WebSocketConfig::default(); + assert!(Some(MAX_WS_MESSAGE_SIZE) < default.max_message_size); + assert!(Some(MAX_WS_MESSAGE_SIZE) < default.max_frame_size); + } + + #[test] + fn websocket_bound_matches_the_noise_message_ceiling() { + // snow rejects transport messages above 65535 bytes, so the bound + // admits every decryptable frame and nothing more. + let mut initiator = snow::Builder::new("Noise_NN_P256_AESGCM_SHA256".parse().unwrap()) + .build_initiator() + .unwrap(); + let mut responder = snow::Builder::new("Noise_NN_P256_AESGCM_SHA256".parse().unwrap()) + .build_responder() + .unwrap(); + let mut a = [0u8; 1024]; + let mut b = [0u8; 1024]; + let n = initiator.write_message(&[], &mut a).unwrap(); + responder.read_message(&a[..n], &mut b).unwrap(); + let n = responder.write_message(&[], &mut a).unwrap(); + initiator.read_message(&a[..n], &mut b).unwrap(); + let mut responder = responder.into_transport_mode().unwrap(); + let mut out = vec![0u8; MAX_WS_MESSAGE_SIZE + 1]; + assert!(matches!( + responder.read_message(&vec![0u8; MAX_WS_MESSAGE_SIZE + 1], &mut out), + Err(snow::Error::Input) + )); + assert!(matches!( + responder.read_message(&vec![0u8; MAX_WS_MESSAGE_SIZE], &mut out), + Err(snow::Error::Decrypt) + )); + } + fn known_device_connection_type(public_key: Vec) -> CableTunnelConnectionType { CableTunnelConnectionType::KnownDevice { contact_id: "contact-id".to_string(), diff --git a/libwebauthn/src/transport/channel.rs b/libwebauthn/src/transport/channel.rs index 461db570..14996453 100644 --- a/libwebauthn/src/transport/channel.rs +++ b/libwebauthn/src/transport/channel.rs @@ -10,6 +10,7 @@ use crate::proto::{ ctap1::apdu::{ApduRequest, ApduResponse}, ctap2::cbor::{CborRequest, CborResponse}, }; +use crate::transport::cable::CableLingerConfig; use crate::webauthn::error::WebAuthnError; use crate::Transport; use crate::UvUpdate; @@ -37,6 +38,13 @@ pub struct ChannelSettings { /// credential management reuses a stored token across sessions instead of /// re-prompting for the PIN. See [`PersistentTokenStore`]. pub persistent_token_store: Option>, + /// Opt-in to keeping a hybrid connection open after the ceremony to capture + /// a late linking update. Enables close-on-new for this channel and lets + /// it linger when the caller closes it with + /// [`CableClose::Linger`](crate::transport::cable::CableClose::Linger) + /// afterwards. An immediate close or a drop captures nothing. `None` + /// disables it. Ignored by the other transports. + pub cable_linger: Option, } #[async_trait] @@ -71,8 +79,20 @@ pub trait Channel: Send + Sync + Display + Ctap2AuthTokenStore { &self, ) -> Result>; async fn status(&self) -> ChannelStatus; + + /// Graceful close. Hybrid sends its protocol-level goodbye and returns + /// once the connection has been torn down (see + /// [`CableChannel::close`](crate::transport::cable::channel::CableChannel::close) + /// for the lingering variant). HID, BLE and NFC release the link when the + /// channel is dropped, so this is a no-op there. async fn close(&mut self); + /// Hard abort without a protocol-level goodbye. Falls back to + /// [`close`](Self::close), so it is a no-op on HID, BLE and NFC. + async fn cancel(&mut self) { + self.close().await + } + /// The transport this channel speaks over. Drives the registration response /// `transports` member and the `authenticatorAttachment` of both registration /// and assertion responses. diff --git a/libwebauthn/src/transport/mod.rs b/libwebauthn/src/transport/mod.rs index b4cc7014..c4d816df 100644 --- a/libwebauthn/src/transport/mod.rs +++ b/libwebauthn/src/transport/mod.rs @@ -36,6 +36,7 @@ mod channel; #[allow(clippy::module_inception)] mod transport; +pub use cable::{CableClose, CableLingerConfig, CableLingerRegistry}; pub(crate) use channel::{AuthTokenData, Ctap2AuthTokenPermission}; pub use channel::{Channel, ChannelSettings, Ctap2AuthTokenStore};