use std::net::IpAddr; use std::sync::Arc; use dashmap::DashMap; use quicprochat_proto::node_capnp::auth; use sha2::Digest; use subtle::ConstantTimeEq; use tokio::sync::Notify; use zeroize::Zeroizing; use crate::error_codes::*; pub const SESSION_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours pub const PENDING_LOGIN_TTL_SECS: u64 = 300; // 5 minutes pub const RATE_LIMIT_WINDOW_SECS: u64 = 60; pub const RATE_LIMIT_MAX_ENQUEUES: u32 = 100; #[derive(Clone)] pub struct AuthConfig { /// Server bearer token — zeroized on drop to prevent memory disclosure. pub required_token: Option>>, /// When true, a valid bearer token (no session) is accepted and the request's identity/key is used (dev/e2e only). /// CLI flag: --allow-insecure-auth / QPQ_ALLOW_INSECURE_AUTH. pub allow_insecure_identity_from_request: bool, } impl std::fmt::Debug for AuthConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AuthConfig") .field("required_token", &self.required_token.as_ref().map(|_| "[REDACTED]")) .field("allow_insecure_identity_from_request", &self.allow_insecure_identity_from_request) .finish() } } impl AuthConfig { pub fn new(required_token: Option, allow_insecure_identity_from_request: bool) -> Self { let required_token = required_token .filter(|s| !s.is_empty()) .map(|s| Zeroizing::new(s.into_bytes())); Self { required_token, allow_insecure_identity_from_request, } } } #[derive(Clone)] pub struct SessionInfo { #[allow(dead_code)] pub username: String, pub identity_key: Vec, #[allow(dead_code)] pub created_at: u64, pub expires_at: u64, } pub struct PendingLogin { pub state_bytes: Vec, pub created_at: u64, } pub struct RateEntry { pub count: u32, pub window_start: u64, } #[derive(Clone)] pub struct AuthContext { pub token: Vec, pub identity_key: Option>, } pub fn current_timestamp() -> u64 { match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { Ok(d) => d.as_secs(), Err(_) => { tracing::warn!("system time is before UNIX_EPOCH; using 0 for session/rate-limit timestamps"); 0 } } } pub fn check_rate_limit( rate_limits: &DashMap, RateEntry>, token: &[u8], ) -> Result<(), capnp::Error> { let now = current_timestamp(); let mut entry = rate_limits.entry(token.to_vec()).or_insert(RateEntry { count: 0, window_start: now, }); if now - entry.window_start >= RATE_LIMIT_WINDOW_SECS { entry.count = 1; entry.window_start = now; } else { entry.count += 1; if entry.count > RATE_LIMIT_MAX_ENQUEUES { return Err(crate::error_codes::coded_error( E014_RATE_LIMITED, format!( "rate limit exceeded: {} enqueues in {}s window", RATE_LIMIT_MAX_ENQUEUES, RATE_LIMIT_WINDOW_SECS ), )); } } Ok(()) } pub fn validate_auth( cfg: &AuthConfig, sessions: &DashMap, SessionInfo>, auth: Result, capnp::Error>, ) -> Result<(), capnp::Error> { validate_auth_context(cfg, sessions, auth).map(|_| ()) } pub fn validate_auth_context( cfg: &AuthConfig, sessions: &DashMap, SessionInfo>, auth: Result, capnp::Error>, ) -> Result { let auth = auth?; let version = auth.get_version(); if version != 1 { return Err(crate::error_codes::coded_error( E001_BAD_AUTH_VERSION, format!("unsupported auth version {} (expected 1)", version), )); } let token = auth .get_access_token() .map_err(|e| crate::error_codes::coded_error(E020_BAD_PARAMS, format!("auth.accessToken: {e}")))? .to_vec(); if token.is_empty() { return Err(crate::error_codes::coded_error( E002_EMPTY_TOKEN, "auth.version=1 requires non-empty accessToken", )); } if let Some(expected) = &cfg.required_token { if expected.len() == token.len() && bool::from(expected.as_slice().ct_eq(&token)) { return Ok(AuthContext { token, identity_key: None, }); } } if let Some(session) = sessions.get(&token) { let now = current_timestamp(); if session.expires_at > now { let identity = if session.identity_key.is_empty() { None } else { Some(session.identity_key.clone()) }; return Ok(AuthContext { token, identity_key: identity, }); } drop(session); sessions.remove(&token); return Err(crate::error_codes::coded_error( E017_SESSION_EXPIRED, "session token has expired", )); } Err(crate::error_codes::coded_error(E003_INVALID_TOKEN, "invalid accessToken")) } /// Validate a raw bearer token (no Cap'n Proto dependency). /// Used by the WebSocket JSON-RPC bridge. pub fn validate_token_raw( cfg: &AuthConfig, sessions: &DashMap, SessionInfo>, token: &[u8], ) -> Result { if token.is_empty() { return Err("empty access token".to_string()); } // Check static bearer token. if let Some(expected) = &cfg.required_token { if expected.len() == token.len() && bool::from(expected.as_slice().ct_eq(token)) { return Ok(AuthContext { token: token.to_vec(), identity_key: None, }); } } // Check session tokens. if let Some(session) = sessions.get(token) { let now = current_timestamp(); if session.expires_at > now { let identity = if session.identity_key.is_empty() { None } else { Some(session.identity_key.clone()) }; return Ok(AuthContext { token: token.to_vec(), identity_key: identity, }); } drop(session); sessions.remove(token); return Err("session token has expired".to_string()); } Err("invalid access token".to_string()) } pub fn require_identity(auth_ctx: &AuthContext) -> Result<&[u8], capnp::Error> { match auth_ctx.identity_key.as_deref() { Some(ik) => Ok(ik), None => Err(crate::error_codes::coded_error( E003_INVALID_TOKEN, "access token is not identity-bound; login required", )), } } pub fn require_identity_match(auth_ctx: &AuthContext, expected: &[u8]) -> Result<(), capnp::Error> { let ik = require_identity(auth_ctx)?; if ik.len() != expected.len() || !bool::from(ik.ct_eq(expected)) { return Err(crate::error_codes::coded_error( E016_IDENTITY_MISMATCH, "access token is bound to a different identity", )); } Ok(()) } /// When the token is a valid session, require it to match `request_identity`. /// When the token is a bearer token (no identity) and `allow_insecure_identity_from_request` is true, accept the request identity (dev/e2e). pub fn require_identity_or_request( auth_ctx: &AuthContext, request_identity: &[u8], allow_insecure: bool, ) -> Result<(), capnp::Error> { match auth_ctx.identity_key.as_deref() { Some(_) => require_identity_match(auth_ctx, request_identity), None if allow_insecure => Ok(()), None => Err(crate::error_codes::coded_error( E003_INVALID_TOKEN, "access token is not identity-bound; login required", )), } } pub fn fmt_hex(bytes: &[u8]) -> String { let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); format!("{hex}…") } pub fn waiter(waiters: &DashMap, Arc>, recipient_key: &[u8]) -> Arc { waiters .entry(recipient_key.to_vec()) .or_insert_with(|| Arc::new(Notify::new())) .clone() } pub const CONN_RATE_LIMIT_WINDOW_SECS: u64 = 60; pub const CONN_RATE_LIMIT_MAX: u32 = 50; /// Per-IP connection rate limiter. Returns `true` if the connection is allowed. pub fn check_conn_rate_limit( conn_rate_limits: &DashMap, ip: IpAddr, ) -> bool { let now = current_timestamp(); let mut entry = conn_rate_limits.entry(ip).or_insert(RateEntry { count: 0, window_start: now, }); if now - entry.window_start >= CONN_RATE_LIMIT_WINDOW_SECS { entry.count = 1; entry.window_start = now; true } else { entry.count += 1; entry.count <= CONN_RATE_LIMIT_MAX } } pub fn fingerprint(data: &[u8]) -> Vec { sha2::Sha256::digest(data).to_vec() } pub fn coded_error(code: &str, msg: impl std::fmt::Display) -> capnp::Error { crate::error_codes::coded_error(code, msg) }