use std::net::IpAddr; #[cfg(feature = "crypto")] use std::collections::HashMap; #[cfg(feature = "crypto")] use std::collections::HashSet; #[cfg(feature = "crypto")] use std::collections::VecDeque; #[cfg(feature = "crypto")] use std::pin::Pin; #[cfg(feature = "crypto")] use std::sync::{Arc, Mutex}; #[cfg(feature = "crypto")] use std::time::{Duration as StdDuration, Instant}; #[cfg(feature = "crypto")] use tokio::time::Duration; pub use mtp_transport::Policy; /// Callback that looks up a registered client by ID. /// /// Called during login to retrieve a client's public key bundle for signature /// verification, and also during guest ID generation to check whether a random /// candidate collides with a registered client. When used for collision /// checking the `description` argument is `None`. #[cfg(feature = "crypto")] pub type GetExistingClient = Box< dyn Fn( u64, Option, ) -> Pin> + Send>> + Send + Sync, >; /// Callback that assigns a guest (unauthenticated) client ID. /// /// Return `Some(id)` to accept the guest with the given full-width `u64` ID, or /// `None` to reject the connection. /// /// When set to `None` on `HostConfig`, the built-in generator produces a random /// full-width non-zero ID that avoids collisions with registered clients and /// currently connected guests. #[cfg(feature = "crypto")] pub type GuestIdGenerator = Box Pin> + Send>> + Send + Sync>; #[cfg(feature = "crypto")] /// Callback that commits a new registration and returns its non-zero ID. /// /// The host serializes registration commits and remembers successful identity /// assignments for the lifetime of the host. Applications that need retry /// recovery across a host restart should also configure [`FindRegisteredClient`] /// to look up the public identity in persistent storage. pub type CompleteRegister = Box< dyn Fn( mtp_crypto::PublicKeyBundle, Option, ) -> Pin + Send>> + Send + Sync, >; /// Callback that recovers an existing registration by its public identity. /// /// Returning an ID makes a registration retry idempotent: the host can send /// the same final response when the original response was lost after the /// application committed the registration. Returning `None` asks the host to /// invoke [`CompleteRegister`] for a new registration. #[cfg(feature = "crypto")] pub type FindRegisteredClient = Box< dyn Fn( mtp_crypto::PublicKeyBundle, Option, ) -> Pin> + Send>> + Send + Sync, >; #[cfg(feature = "crypto")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthenticationPolicy { ForceAuthentication, AllowAuthentication, Unauthenticated, } /// Transport-supplied identity used to scope authentication attempt limits. /// Concrete hosts should populate these fields from the accepted connection; /// the zero/empty defaults exist only for transport-neutral callers. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AuthenticationContext { pub peer_network_identity: Option, pub connection_id: u64, } #[cfg(feature = "crypto")] #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthenticationAttempt { pub peer_network_identity: Option, pub connection_id: u64, pub claimed_client_id: Option, pub registration: bool, } #[cfg(feature = "crypto")] #[derive(Debug, thiserror::Error)] pub enum AuthenticationLimitError { #[error("authentication limiter storage is unavailable")] Store, } #[cfg(feature = "crypto")] pub trait AuthenticationAttemptLimiter: Send + Sync { fn allow(&self, context: &AuthenticationAttempt) -> Result; } #[cfg(feature = "crypto")] #[derive(Debug, Clone, Hash, PartialEq, Eq)] enum AuthenticationLimitKey { Peer(String), Connection(u64), Client(u64), Registration, Global, } #[cfg(feature = "crypto")] #[derive(Debug)] pub struct InMemoryAuthenticationAttemptLimiter { max_attempts: usize, window: StdDuration, max_keys: usize, by_peer: bool, by_connection: bool, by_client: bool, by_registration: bool, attempts: Mutex>>, } #[cfg(feature = "crypto")] impl InMemoryAuthenticationAttemptLimiter { pub fn new(max_attempts: usize, window: StdDuration) -> Self { Self { max_attempts, window, max_keys: 100_000, by_peer: true, by_connection: true, by_client: true, by_registration: true, attempts: Mutex::new(HashMap::new()), } } pub fn with_keys( mut self, by_peer: bool, by_connection: bool, by_client: bool, by_registration: bool, ) -> Self { self.by_peer = by_peer; self.by_connection = by_connection; self.by_client = by_client; self.by_registration = by_registration; self } pub fn with_max_keys(mut self, max_keys: usize) -> Self { self.max_keys = max_keys.max(1); self } fn keys(&self, context: &AuthenticationAttempt) -> Vec { let mut keys = Vec::with_capacity(5); if self.by_peer && let Some(peer) = context.peer_network_identity.as_ref() { keys.push(AuthenticationLimitKey::Peer(peer.clone())); } if self.by_connection && context.connection_id != 0 { keys.push(AuthenticationLimitKey::Connection(context.connection_id)); } if self.by_client && let Some(client_id) = context.claimed_client_id { keys.push(AuthenticationLimitKey::Client(client_id)); } if self.by_registration && context.registration { keys.push(AuthenticationLimitKey::Registration); } // Keep one global bucket as a backstop when an attacker varies the // claimed client ID or presents no peer/connection identity. keys.push(AuthenticationLimitKey::Global); keys } } #[cfg(feature = "crypto")] impl AuthenticationAttemptLimiter for InMemoryAuthenticationAttemptLimiter { fn allow(&self, context: &AuthenticationAttempt) -> Result { if self.max_attempts == 0 { return Ok(false); } let now = Instant::now(); let cutoff = now.checked_sub(self.window); let keys = self.keys(context); let mut attempts = self .attempts .lock() .map_err(|_| AuthenticationLimitError::Store)?; for key in &keys { if let Some(history) = attempts.get_mut(key) { while history .front() .is_some_and(|timestamp| cutoff.is_some_and(|cutoff| *timestamp <= cutoff)) { history.pop_front(); } if history.len() >= self.max_attempts { return Ok(false); } } } for key in keys { if !attempts.contains_key(&key) && attempts.len() >= self.max_keys { if let Some(oldest) = attempts.keys().next().cloned() { attempts.remove(&oldest); } } attempts.entry(key).or_default().push_back(now); } Ok(true) } } pub struct HostConfig { pub ip: IpAddr, pub port: u16, pub tls_fullchain: Vec, pub tls_key: Vec, pub policy: Policy, pub send_pongs: bool, #[cfg(feature = "crypto")] pub authentication_policy: AuthenticationPolicy, #[cfg(feature = "crypto")] pub auth_timeout: Duration, #[cfg(feature = "crypto")] pub require_pq: bool, #[cfg(feature = "crypto")] pub host_keyring: mtp_crypto::Keyring, #[cfg(feature = "crypto")] pub get_existing_client: GetExistingClient, #[cfg(feature = "crypto")] pub(crate) active_guest_ids: Arc>>, #[cfg(feature = "crypto")] pub(crate) registration_ids: Arc, u64>>>, #[cfg(feature = "crypto")] pub(crate) registration_lock: Arc>, #[cfg(feature = "crypto")] pub guest_id_generator: Option, #[cfg(feature = "crypto")] pub complete_register: CompleteRegister, #[cfg(feature = "crypto")] pub find_registered_client: Option, #[cfg(feature = "crypto")] pub auth_limiter: Arc, #[cfg(feature = "crypto")] pub conceal_authentication_identities: bool, } impl HostConfig { pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec, tls_key: Vec) -> Self { Self { ip, port, tls_fullchain, tls_key, policy: Policy::default(), send_pongs: true, #[cfg(feature = "crypto")] authentication_policy: AuthenticationPolicy::Unauthenticated, #[cfg(feature = "crypto")] auth_timeout: Duration::from_secs(30), #[cfg(feature = "crypto")] require_pq: true, #[cfg(feature = "crypto")] host_keyring: mtp_crypto::Keyring::new( mtp_crypto::KemPublicKey::new(Vec::new()), mtp_crypto::KemPrivateKey::new(Vec::new()), mtp_crypto::SignaturePqPublicKey::new(Vec::new()), mtp_crypto::SignaturePqPrivateKey::new(Vec::new()), mtp_crypto::SignaturePublicKey::new(Vec::new()), mtp_crypto::SignaturePrivateKey::new(Vec::new()), ), #[cfg(feature = "crypto")] get_existing_client: Box::new(|_, _| Box::pin(async { None })), #[cfg(feature = "crypto")] active_guest_ids: Arc::new(Mutex::new(HashSet::new())), #[cfg(feature = "crypto")] registration_ids: Arc::new(Mutex::new(HashMap::new())), #[cfg(feature = "crypto")] registration_lock: Arc::new(tokio::sync::Mutex::new(())), #[cfg(feature = "crypto")] guest_id_generator: None, #[cfg(feature = "crypto")] complete_register: Box::new(|_, _| Box::pin(async { 0 })), #[cfg(feature = "crypto")] find_registered_client: None, #[cfg(feature = "crypto")] auth_limiter: Arc::new(InMemoryAuthenticationAttemptLimiter::new( 32, StdDuration::from_secs(60), )), #[cfg(feature = "crypto")] conceal_authentication_identities: true, } } pub fn with_policy(mut self, policy: Policy) -> Self { self.policy = policy; self } pub fn with_pongs(mut self, send_pongs: bool) -> Self { self.send_pongs = send_pongs; self } #[cfg(feature = "crypto")] pub fn with_authentication( mut self, host_keyring: mtp_crypto::Keyring, get_existing_client: GetExistingClient, complete_register: CompleteRegister, ) -> Self { self.authentication_policy = AuthenticationPolicy::ForceAuthentication; self.host_keyring = host_keyring; self.get_existing_client = Box::new(get_existing_client); self.complete_register = Box::new(complete_register); self } #[cfg(feature = "crypto")] pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self { self.authentication_policy = policy; self } #[cfg(feature = "crypto")] pub fn with_auth_timeout(mut self, timeout: Duration) -> Self { self.auth_timeout = timeout; self } #[cfg(feature = "crypto")] pub fn with_require_pq(mut self, require_pq: bool) -> Self { self.require_pq = require_pq; self } #[cfg(feature = "crypto")] pub fn with_guest_id_generator(mut self, generator: GuestIdGenerator) -> Self { self.guest_id_generator = Some(generator); self } /// Configure the lookup used to make registration retries idempotent. #[cfg(feature = "crypto")] pub fn with_registration_lookup(mut self, lookup: FindRegisteredClient) -> Self { self.find_registered_client = Some(lookup); self } #[cfg(feature = "crypto")] pub fn with_authentication_limiter( mut self, limiter: Arc, ) -> Self { self.auth_limiter = limiter; self } #[cfg(feature = "crypto")] pub fn with_authentication_identity_concealment(mut self, conceal: bool) -> Self { self.conceal_authentication_identities = conceal; self } } #[cfg(all(test, feature = "crypto"))] mod tests { use super::*; #[test] fn authentication_attempt_limiter_rejects_repeated_attempts() { let limiter = InMemoryAuthenticationAttemptLimiter::new(1, StdDuration::from_secs(60)) .with_keys(false, true, false, false); let attempt = AuthenticationAttempt { peer_network_identity: None, connection_id: 9, claimed_client_id: Some(42), registration: false, }; assert!(limiter.allow(&attempt).expect("first attempt decision")); assert!(!limiter.allow(&attempt).expect("second attempt decision")); } #[test] fn authentication_attempt_limiter_can_scope_registration_separately() { let limiter = InMemoryAuthenticationAttemptLimiter::new(2, StdDuration::from_secs(60)) .with_keys(false, false, false, true); let login = AuthenticationAttempt { peer_network_identity: None, connection_id: 1, claimed_client_id: None, registration: false, }; let registration = AuthenticationAttempt { registration: true, ..login.clone() }; assert!(limiter.allow(&login).expect("login attempt decision")); assert!( limiter .allow(®istration) .expect("registration attempt decision") ); assert!( !limiter .allow(®istration) .expect("repeated registration decision") ); } }