fix: improve login authentication against digest replay and stale timestamps (#24)

Add a ±180s timestamp window and in-process digest replay cache; warn when auth.token is empty
This commit is contained in:
lxien
2026-09-17 01:46:48 +08:00
parent 983c6a9b1e
commit 01284e0cd5
7 changed files with 153 additions and 18 deletions
@@ -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<String> loginFuture = new CompletableFuture<>();
+3
View File
@@ -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 }
}
+38 -1
View File
@@ -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())
}
}
+41
View File
@@ -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<HashMap<String, Instant>>,
}
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<String, Instant>, now: Instant) {
map.retain(|_, exp| *exp > now);
}
+41 -14
View File
@@ -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)
}
+6
View File
@@ -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<Mutex<SessionMap>>,
pub(crate) agents: Arc<AgentRegistry>,
pub(crate) auth_replay: Arc<ReplayCache>,
http_gw: Option<Arc<HttpGw>>,
https_gw: Option<Arc<HttpsGw>>,
tls_config: Arc<rustls::ServerConfig>,
@@ -36,6 +38,9 @@ pub struct Service {
impl Service {
pub fn new(cfg: ServerConfig) -> Result<Self> {
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,
+21 -3
View File
@@ -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
));
}