diff --git a/Cargo.lock b/Cargo.lock index 813fcad..76e6443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,12 +71,14 @@ dependencies = [ "async-trait", "bytes", "futures", + "hex", "iroh", "quinn", "rustls", "rustls-pki-types", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "toml", diff --git a/crates/alknet-core/Cargo.toml b/crates/alknet-core/Cargo.toml index 05e00f2..5974af0 100644 --- a/crates/alknet-core/Cargo.toml +++ b/crates/alknet-core/Cargo.toml @@ -29,4 +29,6 @@ tracing = "0.1" thiserror = "2" zeroize = { version = "1", features = ["alloc", "derive"] } bytes = "1" -futures = "0.3" \ No newline at end of file +futures = "0.3" +sha2 = "0.10" +hex = "0.4" \ No newline at end of file diff --git a/crates/alknet-core/src/auth.rs b/crates/alknet-core/src/auth.rs index 5e6b95e..08f6e3b 100644 --- a/crates/alknet-core/src/auth.rs +++ b/crates/alknet-core/src/auth.rs @@ -5,6 +5,11 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::config::DynamicConfig; #[derive(Debug, Clone, PartialEq)] pub struct Identity { @@ -13,6 +18,11 @@ pub struct Identity { pub resources: HashMap>, } +#[derive(Debug, Clone)] +pub struct AuthToken { + pub raw: Vec, +} + #[derive(Clone)] pub struct AuthContext { pub identity: Option, @@ -20,3 +30,259 @@ pub struct AuthContext { pub remote_addr: Option, pub tls_client_fingerprint: Option, } + +pub trait IdentityProvider: Send + Sync + 'static { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option; + fn resolve_from_token(&self, token: &AuthToken) -> Option; +} + +pub struct ConfigIdentityProvider { + dynamic: Arc>, +} + +impl ConfigIdentityProvider { + pub fn new(dynamic: Arc>) -> Self { + Self { dynamic } + } +} + +impl IdentityProvider for ConfigIdentityProvider { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option { + let config = self.dynamic.load(); + config.auth.resolve_identity_from_fingerprint(fingerprint) + } + + fn resolve_from_token(&self, token: &AuthToken) -> Option { + let config = self.dynamic.load(); + let token_str = String::from_utf8_lossy(&token.raw); + config.auth.resolve_api_key(&token_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ApiKeyEntry, AuthPolicy, DynamicConfig, RateLimitConfig}; + + fn compute_api_key_hash(token: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let result = hasher.finalize(); + format!("sha256:{}", hex::encode(result)) + } + + fn make_provider( + config: DynamicConfig, + ) -> (ConfigIdentityProvider, Arc>) { + let arc_swap = Arc::new(ArcSwap::new(Arc::new(config))); + let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap)); + (provider, arc_swap) + } + + fn config_with_fingerprint(fingerprint: &str) -> DynamicConfig { + let mut fingerprints = std::collections::HashSet::new(); + fingerprints.insert(fingerprint.to_string()); + DynamicConfig { + auth: AuthPolicy { + authorized_fingerprints: fingerprints, + api_keys: Vec::new(), + }, + rate_limits: RateLimitConfig::default(), + } + } + + fn config_with_api_key(entry: ApiKeyEntry) -> DynamicConfig { + DynamicConfig { + auth: AuthPolicy { + authorized_fingerprints: std::collections::HashSet::new(), + api_keys: vec![entry], + }, + rate_limits: RateLimitConfig::default(), + } + } + + #[test] + fn identity_fields_and_equality() { + let mut resources = HashMap::new(); + resources.insert( + "service".to_string(), + vec!["gitea".to_string(), "registry".to_string()], + ); + let id = Identity { + id: "SHA256:abc123".to_string(), + scopes: vec!["relay:connect".to_string()], + resources, + }; + let id2 = id.clone(); + assert_eq!(id, id2); + assert_eq!(id.id, "SHA256:abc123"); + } + + #[test] + fn auth_token_is_clone() { + let token = AuthToken { + raw: b"alk_test".to_vec(), + }; + let cloned = token.clone(); + assert_eq!(token.raw, cloned.raw); + } + + #[test] + fn auth_context_is_clone() { + let ctx = AuthContext { + identity: None, + alpn: b"alknet/test".to_vec(), + remote_addr: None, + tls_client_fingerprint: None, + }; + let cloned = ctx.clone(); + assert_eq!(cloned.alpn, b"alknet/test"); + assert!(cloned.identity.is_none()); + } + + #[test] + fn fingerprint_resolution_known_returns_some() { + let (provider, _) = make_provider(config_with_fingerprint("SHA256:abc123")); + let identity = provider + .resolve_from_fingerprint("SHA256:abc123") + .expect("known fingerprint resolves"); + assert_eq!(identity.id, "SHA256:abc123"); + assert_eq!(identity.scopes, vec!["relay:connect".to_string()]); + assert!(identity.resources.is_empty()); + } + + #[test] + fn fingerprint_resolution_unknown_returns_none() { + let (provider, _) = make_provider(config_with_fingerprint("SHA256:abc123")); + assert!(provider + .resolve_from_fingerprint("SHA256:unknown") + .is_none()); + } + + #[test] + fn fingerprint_resolution_empty_config_returns_none() { + let (provider, _) = make_provider(DynamicConfig::default()); + assert!(provider + .resolve_from_fingerprint("SHA256:anything") + .is_none()); + } + + #[test] + fn token_resolution_valid_non_expired_returns_some() { + let token_str = "alk_testsecret123"; + let hash = compute_api_key_hash(token_str); + let entry = ApiKeyEntry { + prefix: "alk_test".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "test key".to_string(), + expires_at: None, + }; + let (provider, _) = make_provider(config_with_api_key(entry)); + let token = AuthToken { + raw: token_str.as_bytes().to_vec(), + }; + let identity = provider + .resolve_from_token(&token) + .expect("valid non-expired token resolves"); + assert_eq!(identity.id, "alk_test"); + assert_eq!(identity.scopes, vec!["relay:connect".to_string()]); + } + + #[test] + fn token_resolution_expired_returns_none() { + let token_str = "alk_testsecret123"; + let hash = compute_api_key_hash(token_str); + let entry = ApiKeyEntry { + prefix: "alk_test".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "expired key".to_string(), + expires_at: Some(1), + }; + let (provider, _) = make_provider(config_with_api_key(entry)); + let token = AuthToken { + raw: token_str.as_bytes().to_vec(), + }; + assert!(provider.resolve_from_token(&token).is_none()); + } + + #[test] + fn token_resolution_unknown_returns_none() { + let token_str = "alk_testsecret123"; + let hash = compute_api_key_hash(token_str); + let entry = ApiKeyEntry { + prefix: "alk_test".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "test key".to_string(), + expires_at: None, + }; + let (provider, _) = make_provider(config_with_api_key(entry)); + let token = AuthToken { + raw: b"alk_unknown".to_vec(), + }; + assert!(provider.resolve_from_token(&token).is_none()); + } + + #[test] + fn token_resolution_wrong_hash_returns_none() { + let entry = ApiKeyEntry { + prefix: "alk_test".to_string(), + hash: "sha256:deadbeef".to_string(), + scopes: vec!["relay:connect".to_string()], + description: "wrong hash".to_string(), + expires_at: None, + }; + let (provider, _) = make_provider(config_with_api_key(entry)); + let token = AuthToken { + raw: b"alk_testsecret123".to_vec(), + }; + assert!(provider.resolve_from_token(&token).is_none()); + } + + #[test] + fn token_resolution_non_alk_prefix_returns_none() { + let (provider, _) = make_provider(DynamicConfig::default()); + let token = AuthToken { + raw: b"bearer_token".to_vec(), + }; + assert!(provider.resolve_from_token(&token).is_none()); + } + + #[test] + fn config_reload_changes_resolution_immediately() { + let (provider, arc_swap) = make_provider(DynamicConfig::default()); + assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none()); + + let new_config = config_with_fingerprint("SHA256:abc123"); + arc_swap.store(Arc::new(new_config)); + + assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some()); + } + + #[test] + fn config_reload_removes_fingerprint_access_immediately() { + let (provider, arc_swap) = make_provider(config_with_fingerprint("SHA256:abc123")); + assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some()); + + arc_swap.store(Arc::new(DynamicConfig::default())); + + assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none()); + } + + #[test] + fn config_reload_handle_reloads_config() { + use crate::config::ConfigReloadHandle; + let arc_swap = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default()))); + let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap)); + let handle = ConfigReloadHandle::new(arc_swap); + + assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none()); + + handle.reload(config_with_fingerprint("SHA256:abc123")); + + assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some()); + } +} diff --git a/crates/alknet-core/src/config.rs b/crates/alknet-core/src/config.rs index cab2ece..d5203c6 100644 --- a/crates/alknet-core/src/config.rs +++ b/crates/alknet-core/src/config.rs @@ -1,6 +1,128 @@ -//! Configuration: `StaticConfig`, `DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`, -//! `RateLimitConfig`, `ConfigReloadHandle`, `ConfigError`, `TlsIdentity`. +//! Configuration: `DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`, +//! `RateLimitConfig`, `ConfigReloadHandle`. //! //! See `docs/architecture/crates/core/config.md` for the full specification. +//! +//! This module provides the dynamic-config types required by +//! `auth::ConfigIdentityProvider`. The remaining types (`StaticConfig`, +//! `TlsIdentity`, `ConfigError`) are filled in by the core/config task. -// TODO: implement +use std::collections::HashSet; +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::auth::Identity; + +pub const API_KEY_PREFIX: &str = "alk_"; + +#[derive(Debug, Clone)] +pub struct ApiKeyEntry { + pub prefix: String, + pub hash: String, + pub scopes: Vec, + pub description: String, + pub expires_at: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct AuthPolicy { + pub authorized_fingerprints: HashSet, + pub api_keys: Vec, +} + +impl AuthPolicy { + pub fn empty() -> Self { + Self::default() + } + + pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option { + if self.authorized_fingerprints.contains(fingerprint) { + Some(Identity { + id: fingerprint.to_string(), + scopes: vec!["relay:connect".to_string()], + resources: std::collections::HashMap::new(), + }) + } else { + None + } + } + + pub fn resolve_api_key(&self, token: &str) -> Option { + if !token.starts_with(API_KEY_PREFIX) { + return None; + } + + let prefix_part = &token[..token.len().min(8)]; + + let entry = self + .api_keys + .iter() + .find(|e| prefix_part.starts_with(&e.prefix))?; + + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let result = hasher.finalize(); + let expected_hash = format!("sha256:{}", hex::encode(result)); + + if entry.hash != expected_hash { + return None; + } + + if let Some(expires_at) = entry.expires_at { + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now_secs >= expires_at { + return None; + } + } + + Some(Identity { + id: entry.prefix.clone(), + scopes: entry.scopes.clone(), + resources: std::collections::HashMap::new(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct RateLimitConfig { + pub max_connections_per_ip: usize, + pub max_auth_attempts: usize, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + max_connections_per_ip: 100, + max_auth_attempts: 5, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct DynamicConfig { + pub auth: AuthPolicy, + pub rate_limits: RateLimitConfig, +} + +pub struct ConfigReloadHandle { + dynamic: Arc>, +} + +impl ConfigReloadHandle { + pub fn new(dynamic: Arc>) -> Self { + Self { dynamic } + } + + pub fn reload(&self, new_config: DynamicConfig) { + self.dynamic.store(Arc::new(new_config)); + } + + pub fn dynamic(&self) -> Arc { + self.dynamic.load_full() + } +}