diff --git a/Cargo.lock b/Cargo.lock index d4a9934..d2ac1c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,7 @@ dependencies = [ "arc-swap", "async-trait", "futures", + "hex", "ipnetwork", "iroh", "rand 0.10.1", @@ -89,6 +90,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", + "sha2", "ssh-key", "tempfile", "thiserror 2.0.18", diff --git a/crates/alknet-core/Cargo.toml b/crates/alknet-core/Cargo.toml index f25d4ff..158371f 100644 --- a/crates/alknet-core/Cargo.toml +++ b/crates/alknet-core/Cargo.toml @@ -38,6 +38,8 @@ ipnetwork = "0.21.1" arc-swap = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" +hex = "0.4" [dev-dependencies] alknet-core = { path = ".", features = ["testutil", "tls", "iroh"] } diff --git a/crates/alknet-core/src/auth/identity.rs b/crates/alknet-core/src/auth/identity.rs index bd969d6..a7c47a2 100644 --- a/crates/alknet-core/src/auth/identity.rs +++ b/crates/alknet-core/src/auth/identity.rs @@ -45,7 +45,13 @@ impl IdentityProvider for ConfigIdentityProvider { auth.resolve_identity_from_fingerprint(fingerprint) } - fn resolve_from_token(&self, _token: &AuthToken) -> Option { + fn resolve_from_token(&self, token: &AuthToken) -> Option { + let config = self.dynamic.load(); + let auth = &config.auth; + let token_str = String::from_utf8_lossy(&token.raw); + if token_str.starts_with(crate::config::API_KEY_PREFIX) { + return auth.resolve_api_key(&token_str); + } None } } @@ -177,6 +183,148 @@ mod tests { assert!(provider.resolve_from_token(&token).is_none()); } + 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)) + } + + #[test] + fn config_identity_provider_resolves_valid_api_key() { + let token = "alk_testsecret123"; + let hash = compute_api_key_hash(token); + let entry = crate::config::ApiKeyEntry { + prefix: "alk_test".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "test key".to_string(), + expires_at: None, + }; + let auth_policy = crate::config::AuthPolicy::with_api_keys( + std::collections::HashSet::new(), + Vec::new(), + vec![entry], + ); + let dynamic = DynamicConfig::new(auth_policy); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(arc_swap); + + let auth_token = AuthToken { + raw: token.as_bytes().to_vec(), + }; + let identity = provider.resolve_from_token(&auth_token); + assert!(identity.is_some()); + let identity = identity.unwrap(); + assert_eq!(identity.id, "alk_test"); + assert_eq!(identity.scopes, vec!["relay:connect"]); + } + + #[test] + fn config_identity_provider_rejects_expired_api_key() { + let token = "alk_expiredkey1"; + let hash = compute_api_key_hash(token); + let entry = crate::config::ApiKeyEntry { + prefix: "alk_expi".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "expired key".to_string(), + expires_at: Some(1), + }; + let auth_policy = crate::config::AuthPolicy::with_api_keys( + std::collections::HashSet::new(), + Vec::new(), + vec![entry], + ); + let dynamic = DynamicConfig::new(auth_policy); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(arc_swap); + + let auth_token = AuthToken { + raw: token.as_bytes().to_vec(), + }; + assert!(provider.resolve_from_token(&auth_token).is_none()); + } + + #[test] + fn config_identity_provider_rejects_wrong_hash_api_key() { + let entry = crate::config::ApiKeyEntry { + prefix: "alk_test".to_string(), + hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + scopes: vec!["relay:connect".to_string()], + description: "bad hash".to_string(), + expires_at: None, + }; + let auth_policy = crate::config::AuthPolicy::with_api_keys( + std::collections::HashSet::new(), + Vec::new(), + vec![entry], + ); + let dynamic = DynamicConfig::new(auth_policy); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(arc_swap); + + let auth_token = AuthToken { + raw: b"alk_testsecret123".to_vec(), + }; + assert!(provider.resolve_from_token(&auth_token).is_none()); + } + + #[test] + fn config_identity_provider_api_key_unknown_prefix_falls_through() { + let token = "alk_testsecret123"; + let hash = compute_api_key_hash(token); + let entry = crate::config::ApiKeyEntry { + prefix: "alk_other".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "other key".to_string(), + expires_at: None, + }; + let auth_policy = crate::config::AuthPolicy::with_api_keys( + std::collections::HashSet::new(), + Vec::new(), + vec![entry], + ); + let dynamic = DynamicConfig::new(auth_policy); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(arc_swap); + + let auth_token = AuthToken { + raw: token.as_bytes().to_vec(), + }; + assert!(provider.resolve_from_token(&auth_token).is_none()); + } + + #[test] + fn config_identity_provider_api_key_scopes_in_identity() { + let token = "alk_scopedkey12"; + let hash = compute_api_key_hash(token); + let entry = crate::config::ApiKeyEntry { + prefix: "alk_sco".to_string(), + hash, + scopes: vec!["relay:connect".to_string(), "secrets:derive".to_string()], + description: "scoped key".to_string(), + expires_at: None, + }; + let auth_policy = crate::config::AuthPolicy::with_api_keys( + std::collections::HashSet::new(), + Vec::new(), + vec![entry], + ); + let dynamic = DynamicConfig::new(auth_policy); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(arc_swap); + + let auth_token = AuthToken { + raw: token.as_bytes().to_vec(), + }; + let identity = provider.resolve_from_token(&auth_token).unwrap(); + assert_eq!(identity.scopes, vec!["relay:connect", "secrets:derive"]); + } + #[test] fn auth_token_holds_raw_bytes() { let token = AuthToken { raw: vec![1, 2, 3] }; diff --git a/crates/alknet-core/src/config/dynamic_config.rs b/crates/alknet-core/src/config/dynamic_config.rs index 97332d7..ed75e0e 100644 --- a/crates/alknet-core/src/config/dynamic_config.rs +++ b/crates/alknet-core/src/config/dynamic_config.rs @@ -13,9 +13,22 @@ use crate::auth::ServerAuthConfig; use crate::config::forwarding::ForwardingPolicy; use crate::credentials::CredentialSet; +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct ApiKeyEntry { + pub prefix: String, + pub hash: String, + pub scopes: Vec, + pub description: String, + pub expires_at: Option, +} + +pub const API_KEY_PREFIX: &str = "alk_"; + pub struct AuthPolicy { pub authorized_keys: std::collections::HashSet, pub cert_authorities: Vec, + pub api_keys: Vec, encoded_keys: std::collections::HashSet>, fingerprint_to_key: HashMap, } @@ -29,6 +42,14 @@ impl AuthPolicy { pub fn new( authorized_keys: std::collections::HashSet, cert_authorities: Vec, + ) -> Self { + Self::with_api_keys(authorized_keys, cert_authorities, Vec::new()) + } + + pub fn with_api_keys( + authorized_keys: std::collections::HashSet, + cert_authorities: Vec, + api_keys: Vec, ) -> Self { let encoded_keys = authorized_keys.iter().map(encode_key_data).collect(); let fingerprint_to_key = authorized_keys @@ -39,6 +60,7 @@ impl AuthPolicy { Self { authorized_keys, cert_authorities, + api_keys, encoded_keys, fingerprint_to_key, } @@ -64,6 +86,45 @@ impl AuthPolicy { } } + 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: HashMap::new(), + }) + } + pub fn authenticate_publickey( &self, key: &russh::keys::PublicKey, @@ -203,6 +264,7 @@ impl std::fmt::Debug for AuthPolicy { f.debug_struct("AuthPolicy") .field("authorized_keys_count", &self.authorized_keys.len()) .field("cert_authorities_count", &self.cert_authorities.len()) + .field("api_keys_count", &self.api_keys.len()) .finish() } } @@ -212,6 +274,7 @@ impl Clone for AuthPolicy { Self { authorized_keys: self.authorized_keys.clone(), cert_authorities: self.cert_authorities.clone(), + api_keys: self.api_keys.clone(), encoded_keys: self.encoded_keys.clone(), fingerprint_to_key: self.fingerprint_to_key.clone(), } @@ -412,5 +475,129 @@ mod tests { let debug_str = format!("{:?}", policy); assert!(debug_str.contains("authorized_keys_count")); assert!(debug_str.contains("cert_authorities_count")); + assert!(debug_str.contains("api_keys_count")); + } + + 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)) + } + + #[test] + fn api_key_valid_authenticates() { + let token = "alk_testsecret123"; + let hash = compute_api_key_hash(token); + let entry = ApiKeyEntry { + prefix: "alk_test".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "test key".to_string(), + expires_at: None, + }; + let policy = + AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]); + let identity = policy.resolve_api_key(token); + assert!(identity.is_some()); + let identity = identity.unwrap(); + assert_eq!(identity.id, "alk_test"); + assert_eq!(identity.scopes, vec!["relay:connect"]); + } + + #[test] + fn api_key_expired_rejected() { + let token = "alk_expiredkey1"; + let hash = compute_api_key_hash(token); + let entry = ApiKeyEntry { + prefix: "alk_expi".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "expired key".to_string(), + expires_at: Some(1), + }; + let policy = + AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]); + assert!(policy.resolve_api_key(token).is_none()); + } + + #[test] + fn api_key_wrong_hash_rejected() { + let entry = ApiKeyEntry { + prefix: "alk_test".to_string(), + hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + scopes: vec!["relay:connect".to_string()], + description: "bad hash".to_string(), + expires_at: None, + }; + let policy = + AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]); + assert!(policy.resolve_api_key("alk_testsecret123").is_none()); + } + + #[test] + fn api_key_unknown_prefix_falls_through() { + let token = "alk_testsecret123"; + let hash = compute_api_key_hash(token); + let entry = ApiKeyEntry { + prefix: "alk_other".to_string(), + hash, + scopes: vec!["relay:connect".to_string()], + description: "other key".to_string(), + expires_at: None, + }; + let policy = + AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]); + assert!(policy.resolve_api_key(token).is_none()); + } + + #[test] + fn api_key_scopes_propagate() { + let token = "alk_scopesecret"; + let hash = compute_api_key_hash(token); + let entry = ApiKeyEntry { + prefix: "alk_sco".to_string(), + hash, + scopes: vec!["relay:connect".to_string(), "secrets:derive".to_string()], + description: "scoped key".to_string(), + expires_at: None, + }; + let policy = + AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]); + let identity = policy.resolve_api_key(token).unwrap(); + assert_eq!(identity.scopes, vec!["relay:connect", "secrets:derive"]); + } + + #[test] + fn non_api_key_prefix_returns_none() { + let policy = AuthPolicy::empty(); + assert!(policy.resolve_api_key("bearer-some-token").is_none()); + assert!(policy.resolve_api_key("regular-token").is_none()); + } + + #[test] + fn api_key_entry_default_empty() { + let config = DynamicConfig::default(); + assert!(config.auth.api_keys.is_empty()); + } + + #[test] + fn auth_policy_with_api_keys_preserves_entries() { + let entry = ApiKeyEntry { + prefix: "alk_abc".to_string(), + hash: "sha256:abcdef".to_string(), + scopes: vec!["relay:connect".to_string()], + description: "test".to_string(), + expires_at: None, + }; + let policy = AuthPolicy::with_api_keys( + std::collections::HashSet::new(), + Vec::new(), + vec![entry.clone()], + ); + assert_eq!(policy.api_keys.len(), 1); + assert_eq!(policy.api_keys[0], entry); } } diff --git a/crates/alknet-core/src/config/mod.rs b/crates/alknet-core/src/config/mod.rs index a3048be..8243fa3 100644 --- a/crates/alknet-core/src/config/mod.rs +++ b/crates/alknet-core/src/config/mod.rs @@ -5,7 +5,8 @@ pub mod static_config; pub use config_service::ConfigServiceImpl; pub use dynamic_config::{ - new_dynamic_config, AuthPolicy, ConfigReloadHandle, DynamicConfig, RateLimitConfig, + new_dynamic_config, ApiKeyEntry, AuthPolicy, ConfigReloadHandle, DynamicConfig, + RateLimitConfig, API_KEY_PREFIX, }; pub use forwarding::{ForwardingAction, ForwardingPolicy, ForwardingRule, TargetPattern}; pub use static_config::StaticConfig;