diff --git a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java index 94a9409..2559569 100644 --- a/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java +++ b/client-java/orbien-client/src/main/java/io/github/lxien/orbien/client/OrbienClient.java @@ -58,6 +58,9 @@ public final class OrbienClient implements AutoCloseable { started.set(false); throw new IllegalStateException("tcpMux is not supported; set tcpMux=false on client and server"); } + if (config.getToken() == null || config.getToken().isEmpty()) { + log.warn("auth.token is empty; authentication is disabled"); + } group = new NioEventLoopGroup(); CompletableFuture loginFuture = new CompletableFuture<>(); diff --git a/client/src/service.rs b/client/src/service.rs index b1157cd..00e3e42 100644 --- a/client/src/service.rs +++ b/client/src/service.rs @@ -32,6 +32,9 @@ pub struct Service { impl Service { pub fn new(cfg: ClientConfig) -> Self { + if cfg.auth.token.is_empty() { + tracing::warn!("auth.token is empty; authentication is disabled"); + } Self { cfg } } diff --git a/core/src/auth/mod.rs b/core/src/auth/mod.rs index 5aaafe7..88ac442 100644 --- a/core/src/auth/mod.rs +++ b/core/src/auth/mod.rs @@ -1,3 +1,40 @@ +mod replay; mod token; -pub use token::{compute_auth_digest, verify_auth_digest, verify_login}; +pub use replay::ReplayCache; +pub use token::{compute_auth_digest, unix_now_secs, verify_auth_digest, verify_login}; + +use std::fmt; + +pub const AUTH_SKEW_SECS: i64 = 180; + +pub const REPLAY_TTL_SECS: u64 = (AUTH_SKEW_SECS as u64) * 2; + +pub const REPLAY_MAX_ENTRIES: usize = 100_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFailure { + EmptyDigest, + InvalidDigest, + TimestampSkew, + Replay, + Capacity, +} + +impl AuthFailure { + pub fn as_str(self) -> &'static str { + match self { + Self::EmptyDigest => "empty authentication digest", + Self::InvalidDigest => "invalid authentication digest", + Self::TimestampSkew => "timestamp outside allowed window", + Self::Replay => "authentication digest reused", + Self::Capacity => "authentication replay cache full", + } + } +} + +impl fmt::Display for AuthFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/core/src/auth/replay.rs b/core/src/auth/replay.rs new file mode 100644 index 0000000..679d4fc --- /dev/null +++ b/core/src/auth/replay.rs @@ -0,0 +1,41 @@ +use super::{AuthFailure, REPLAY_MAX_ENTRIES, REPLAY_TTL_SECS}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +pub struct ReplayCache { + inner: Mutex>, +} + +impl ReplayCache { + pub fn new() -> Self { + Self::default() + } + + pub fn accept(&self, digest: &str) -> Result<(), AuthFailure> { + self.accept_at(digest, Instant::now()) + } + + pub fn accept_at(&self, digest: &str, now: Instant) -> Result<(), AuthFailure> { + let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + purge_expired(&mut map, now); + + if map.contains_key(digest) { + return Err(AuthFailure::Replay); + } + if map.len() >= REPLAY_MAX_ENTRIES { + return Err(AuthFailure::Capacity); + } + + map.insert( + digest.to_owned(), + now + Duration::from_secs(REPLAY_TTL_SECS), + ); + Ok(()) + } +} + +fn purge_expired(map: &mut HashMap, now: Instant) { + map.retain(|_, exp| *exp > now); +} diff --git a/core/src/auth/token.rs b/core/src/auth/token.rs index a858dcc..bf52ccb 100644 --- a/core/src/auth/token.rs +++ b/core/src/auth/token.rs @@ -1,3 +1,4 @@ +use super::{AuthFailure, ReplayCache, AUTH_SKEW_SECS}; use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -10,23 +11,49 @@ pub fn compute_auth_digest(token: &str, timestamp: i64) -> String { hex::encode(mac.finalize().into_bytes()) } -pub fn verify_login(token: &str, auth_digest: &str, timestamp: i64) -> bool { - verify_auth_digest(token, auth_digest, timestamp) -} - -pub fn verify_auth_digest(token: &str, auth_digest: &str, timestamp: i64) -> bool { +pub fn verify_auth_digest( + token: &str, + auth_digest: &str, + timestamp: i64, + now_secs: i64, + replay: Option<&ReplayCache>, +) -> Result<(), AuthFailure> { if token.is_empty() { - return true; + return Ok(()); } if auth_digest.is_empty() { - return false; + return Err(AuthFailure::EmptyDigest); } - let Ok(expected) = hex::decode(auth_digest) else { - return false; - }; - let Ok(mut mac) = HmacSha256::new_from_slice(token.as_bytes()) else { - return false; - }; + if (now_secs - timestamp).abs() > AUTH_SKEW_SECS { + return Err(AuthFailure::TimestampSkew); + } + + let expected = hex::decode(auth_digest).map_err(|_| AuthFailure::InvalidDigest)?; + let mut mac = + HmacSha256::new_from_slice(token.as_bytes()).map_err(|_| AuthFailure::InvalidDigest)?; mac.update(timestamp.to_string().as_bytes()); - mac.verify_slice(&expected).is_ok() + mac.verify_slice(&expected) + .map_err(|_| AuthFailure::InvalidDigest)?; + + if let Some(cache) = replay { + cache.accept(auth_digest)?; + } + Ok(()) +} + +pub fn verify_login( + token: &str, + auth_digest: &str, + timestamp: i64, + now_secs: i64, + replay: &ReplayCache, +) -> Result<(), AuthFailure> { + verify_auth_digest(token, auth_digest, timestamp, now_secs, Some(replay)) +} + +pub fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) } diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 0e0d343..7865a01 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -9,6 +9,7 @@ use crate::tunnel::{ }; use agent_registry::AgentRegistry; use anyhow::{anyhow, Result}; +use orbien_core::auth::ReplayCache; use orbien_core::config::ServerConfig; use orbien_core::transport; use session_table::SessionMap; @@ -24,6 +25,7 @@ pub struct Service { cfg: ServerConfig, pub(crate) controls: Arc>, pub(crate) agents: Arc, + pub(crate) auth_replay: Arc, http_gw: Option>, https_gw: Option>, tls_config: Arc, @@ -36,6 +38,9 @@ pub struct Service { impl Service { pub fn new(cfg: ServerConfig) -> Result { + if cfg.auth.token.is_empty() { + tracing::warn!("auth.token is empty; authentication is disabled"); + } let http_gw = if cfg.http_gw_enabled() { Some(Arc::new(HttpGw::new(cfg.http_gw_port))) } else { @@ -56,6 +61,7 @@ impl Service { cfg, controls: Arc::new(Mutex::new(HashMap::new())), agents: Arc::new(AgentRegistry::new()), + auth_replay: Arc::new(ReplayCache::new()), http_gw, https_gw, tls_config, diff --git a/server/src/service/session_registry.rs b/server/src/service/session_registry.rs index 5308572..f6bc7a9 100644 --- a/server/src/service/session_registry.rs +++ b/server/src/service/session_registry.rs @@ -22,7 +22,14 @@ impl Service { login: Login, peer: SocketAddr, ) -> Result<()> { - if !auth::verify_login(&self.cfg.auth.token, &login.auth_digest, login.timestamp) { + if let Err(reason) = auth::verify_login( + &self.cfg.auth.token, + &login.auth_digest, + login.timestamp, + auth::unix_now_secs(), + &self.auth_replay, + ) { + tracing::warn!(%reason, %peer, "login rejected"); let mut stream = stream; let _ = msg::write_msg( &mut stream, @@ -167,9 +174,20 @@ impl Service { if nw.session_id.trim().is_empty() { return Err(anyhow!("empty session_id for data conn")); } - if !auth::verify_auth_digest(&self.cfg.auth.token, &nw.auth_digest, nw.timestamp) { + if let Err(reason) = auth::verify_auth_digest( + &self.cfg.auth.token, + &nw.auth_digest, + nw.timestamp, + auth::unix_now_secs(), + None, + ) { + tracing::warn!( + %reason, + session_id = %nw.session_id, + "data connection authentication failed" + ); return Err(anyhow!( - "data conn auth failed for session_id={}", + "data connection authentication failed for session_id={}", nw.session_id )); }