diff --git a/crates/lib/src/config.rs b/crates/lib/src/config.rs index 75753669..b4b353c0 100644 --- a/crates/lib/src/config.rs +++ b/crates/lib/src/config.rs @@ -1,8 +1,8 @@ use http::HeaderValue; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use solana_sdk::pubkey::Pubkey; use spl_token_2022_interface::extension::ExtensionType; -use std::{fs, path::Path, str::FromStr}; +use std::{env, fs, path::Path, str::FromStr}; use toml; use url::Url; use utoipa::ToSchema; @@ -859,10 +859,55 @@ impl Default for LighthouseConfig { } } +fn deserialize_api_keys<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum ApiKeys { + String(String), + Vec(Vec), + } + + let opt = Option::::deserialize(deserializer)?; + match opt { + Some(ApiKeys::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + log::warn!("DEPRECATION WARNING: 'api_key' as a single string is deprecated. Please migrate to using 'api_keys' as an array in your configuration."); + Ok(Some(vec![trimmed.to_string()])) + } + } + Some(ApiKeys::Vec(v)) => { + let filtered: Vec = v + .into_iter() + .filter_map(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect(); + if filtered.is_empty() { + Ok(None) + } else { + Ok(Some(filtered)) + } + } + None => Ok(None), + } +} + #[derive(Clone, Serialize, Deserialize, ToSchema)] #[serde(default, deny_unknown_fields)] pub struct AuthConfig { - pub api_key: Option, + #[serde(alias = "api_key", deserialize_with = "deserialize_api_keys")] + pub api_keys: Option>, pub hmac_secret: Option, pub recaptcha_secret: Option, pub recaptcha_score_threshold: f64, @@ -873,7 +918,7 @@ pub struct AuthConfig { impl Default for AuthConfig { fn default() -> Self { Self { - api_key: None, + api_keys: None, hmac_secret: None, recaptcha_secret: None, recaptcha_score_threshold: DEFAULT_RECAPTCHA_SCORE_THRESHOLD, @@ -899,8 +944,12 @@ impl AuthConfig { .or_else(|| Self::normalize_optional_secret(config_value.map(str::to_string))) } - pub(crate) fn resolved_api_key(&self) -> Option { - Self::resolve_secret(Self::API_KEY_ENV, self.api_key.as_deref()) + pub(crate) fn resolved_api_keys(&self) -> Option> { + if let Some(env_key) = Self::normalize_optional_secret(env::var(Self::API_KEY_ENV).ok()) { + Some(vec![env_key]) + } else { + self.api_keys.clone() + } } pub(crate) fn resolved_hmac_secret(&self) -> Option { @@ -913,29 +962,56 @@ impl AuthConfig { /// Whether API-key or HMAC auth is in effect after env-first resolution (what the server enforces). pub(crate) fn has_resolved_auth(&self) -> bool { - self.resolved_api_key().is_some() || self.resolved_hmac_secret().is_some() + self.resolved_api_keys().is_some() || self.resolved_hmac_secret().is_some() } /// Auth fields where a non-empty environment variable overrides a *different* non-empty - /// kora.toml value. Returns `(env_var, config_field_label)`; never returns secret contents. - pub(crate) fn env_overridden_fields(&self) -> Vec<(&'static str, &'static str)> { - [ - (Self::API_KEY_ENV, "[kora.auth].api_key", self.api_key.as_deref()), + /// kora.toml value. Returns fully-formed warning messages. + pub(crate) fn env_overridden_fields(&self) -> Vec { + let mut warnings = Vec::new(); + + let standard_warning = |env_var: &str, field: &str| { + format!( + "⚠️ SECURITY: environment variable {} overrides {}. The environment \ + value takes precedence at runtime; if you rotated the secret in kora.toml, the \ + stale environment value is still in effect. Unset {} or align it with the config.", + env_var, field, env_var + ) + }; + + if let Some(env_val) = Self::normalize_optional_secret(env::var(Self::API_KEY_ENV).ok()) { + if let Some(cfg_keys) = &self.api_keys { + if cfg_keys.len() > 1 { + warnings.push(format!( + "⚠️ SECURITY: environment variable {} overrides ALL {} configured keys \ + in [kora.auth].api_keys. The environment value takes precedence at runtime. \ + Unset {} to use the multiple configured keys.", + Self::API_KEY_ENV, + cfg_keys.len(), + Self::API_KEY_ENV + )); + } else if cfg_keys.len() == 1 && cfg_keys[0] != env_val { + warnings.push(standard_warning(Self::API_KEY_ENV, "[kora.auth].api_keys")); + } + } + } + + for (env_var, label, config_value) in [ (Self::HMAC_SECRET_ENV, "[kora.auth].hmac_secret", self.hmac_secret.as_deref()), ( Self::RECAPTCHA_SECRET_ENV, "[kora.auth].recaptcha_secret", self.recaptcha_secret.as_deref(), ), - ] - .into_iter() - .filter(|(env_var, _, config_value)| { + ] { let env_value = Self::normalize_optional_secret(std::env::var(env_var).ok()); let config_value = Self::normalize_optional_secret(config_value.map(str::to_string)); - matches!((env_value, config_value), (Some(env), Some(cfg)) if env != cfg) - }) - .map(|(env_var, label, _)| (env_var, label)) - .collect() + if matches!((env_value, config_value), (Some(env), Some(cfg)) if env != cfg) { + warnings.push(standard_warning(env_var, label)); + } + } + + warnings } } @@ -1566,4 +1642,46 @@ allow_create = true .build_config(); assert_unknown_field_error(result, "unknown_rule_field"); } + + #[test] + fn test_legacy_api_key_backward_compatibility() { + let toml_str = r#" + api_key = "single-key" + "#; + let auth: AuthConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(auth.api_keys, Some(vec!["single-key".to_string()])); + } + + #[test] + fn test_api_keys_with_empty_strings_filtered() { + let toml_str = r#" + api_keys = ["valid-key", " ", ""] + "#; + let auth: AuthConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(auth.api_keys, Some(vec!["valid-key".to_string()])); + } + + #[test] + fn test_api_keys_all_empty_strings_filtered() { + let toml_str = r#" + api_keys = ["", " "] + "#; + let auth: AuthConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(auth.api_keys, None); + assert_eq!(auth.has_resolved_auth(), false); + } + + #[test] + #[serial_test::serial] + fn test_env_overridden_fields_multiple_keys_backup_dropped() { + let mut auth = AuthConfig::default(); + auth.api_keys = Some(vec!["key1".to_string(), "key2".to_string()]); + + env::set_var("KORA_API_KEY", "env-key"); + + let warnings = auth.env_overridden_fields(); + assert!(warnings.iter().any(|w| w.contains("overrides ALL 2 configured keys"))); + + env::remove_var("KORA_API_KEY"); + } } diff --git a/crates/lib/src/rpc_server/args.rs b/crates/lib/src/rpc_server/args.rs index 225df34d..90fac033 100644 --- a/crates/lib/src/rpc_server/args.rs +++ b/crates/lib/src/rpc_server/args.rs @@ -28,7 +28,7 @@ pub struct RpcArgs { #[derive(Parser)] pub struct AuthArgs { - /// API key for authenticating requests to the Kora server (optional) - can be set in `kora.toml` + /// API key for authenticating requests to the Kora server (optional). Only a single key can be set via CLI or env var. For multiple keys, use `api_keys = [...]` in `kora.toml`. #[arg(long, env = "KORA_API_KEY", help_heading = "Authentication")] pub api_key: Option, diff --git a/crates/lib/src/rpc_server/auth.rs b/crates/lib/src/rpc_server/auth.rs index e068b370..c9c51d30 100644 --- a/crates/lib/src/rpc_server/auth.rs +++ b/crates/lib/src/rpc_server/auth.rs @@ -7,30 +7,51 @@ use crate::{ use hmac::{Hmac, KeyInit, Mac}; use http::{Request, Response, StatusCode}; use jsonrpsee::server::logger::Body; -use sha2::Sha256; +use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; +fn hash_key(key: &[u8]) -> [u8; 32] { + Sha256::digest(key).into() +} + +#[derive(Clone)] +pub struct ClientIdentity(pub String); + +impl std::fmt::Debug for ClientIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some((prefix, rest)) = self.0.split_once(':') { + if rest.is_empty() { + write!(f, "ClientIdentity({}:)", prefix) + } else { + write!(f, "ClientIdentity({}:***)", prefix) + } + } else { + write!(f, "ClientIdentity(***)") + } + } +} + #[derive(Clone)] pub struct ApiKeyAuthLayer { - api_key: String, + api_keys: Vec, } impl ApiKeyAuthLayer { - pub fn new(api_key: String) -> Self { - Self { api_key } + pub fn new(api_keys: Vec) -> Self { + Self { api_keys } } } #[derive(Clone)] pub struct ApiKeyAuthService { inner: S, - api_key: String, + api_keys: Vec, } impl tower::Layer for ApiKeyAuthLayer { type Service = ApiKeyAuthService; fn layer(&self, inner: S) -> Self::Service { - ApiKeyAuthService { inner, api_key: self.api_key.clone() } + ApiKeyAuthService { inner, api_keys: self.api_keys.clone() } } } @@ -53,7 +74,7 @@ where } fn call(&mut self, request: Request) -> Self::Future { - let api_key = self.api_key.clone(); + let api_keys = self.api_keys.clone(); let mut inner = self.inner.clone(); Box::pin(async move { @@ -71,10 +92,24 @@ where } } - let req = Request::from_parts(parts, Body::from(body_bytes)); + let mut req = Request::from_parts(parts, Body::from(body_bytes)); if let Some(provided_key) = req.headers().get(X_API_KEY) { - // Constant-time comparison prevents timing attacks - if provided_key.as_bytes().ct_eq(api_key.as_bytes()).into() { + let mut is_valid = false; + let mut matched_id = String::new(); + let provided_hash = hash_key(provided_key.as_bytes()); + + for configured_key in api_keys.iter() { + let configured_hash = hash_key(configured_key.as_bytes()); + let matches: bool = provided_hash.ct_eq(&configured_hash).into(); + + if matches { + is_valid = true; + matched_id = hex::encode(&configured_hash[..4]); + } + } + + if is_valid { + req.extensions_mut().insert(ClientIdentity(format!("apikey:{}", matched_id))); return inner.call(req).await; } } @@ -231,7 +266,7 @@ mod tests { use jsonrpsee::server::logger::Body; use sha2::Sha256; use std::{ - future::Ready, + future::{self, Ready}, task::{Context, Poll}, }; use tower::{Layer, Service, ServiceExt}; @@ -248,14 +283,18 @@ mod tests { Poll::Ready(Ok(())) } - fn call(&mut self, _: Request) -> Self::Future { - std::future::ready(Ok(Response::builder().status(200).body(Body::empty()).unwrap())) + fn call(&mut self, req: Request) -> Self::Future { + let mut res = Response::builder().status(200).body(Body::empty()).unwrap(); + if let Some(id) = req.extensions().get::() { + res.extensions_mut().insert(id.clone()); + } + future::ready(Ok(res)) } } #[tokio::test] async fn test_api_key_auth_valid_key() { - let layer = ApiKeyAuthLayer::new("test-key".to_string()); + let layer = ApiKeyAuthLayer::new(vec!["test-key".to_string()]); let mut service = layer.layer(MockService); let body = r#"{"jsonrpc":"2.0","method":"getConfig","id":1}"#; let request = Request::builder() @@ -266,11 +305,16 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); + let id = response + .extensions() + .get::() + .expect("ClientIdentity should be present"); + assert_eq!(id.0, "apikey:62af8704"); } #[tokio::test] async fn test_api_key_auth_invalid_key() { - let layer = ApiKeyAuthLayer::new("test-key".to_string()); + let layer = ApiKeyAuthLayer::new(vec!["test-key".to_string()]); let mut service = layer.layer(MockService); let body = r#"{"jsonrpc":"2.0","method":"getConfig","id":1}"#; let request = Request::builder() @@ -285,7 +329,7 @@ mod tests { #[tokio::test] async fn test_api_key_auth_missing_header() { - let layer = ApiKeyAuthLayer::new("test-key".to_string()); + let layer = ApiKeyAuthLayer::new(vec!["test-key".to_string()]); let mut service = layer.layer(MockService); let body = r#"{"jsonrpc":"2.0","method":"getConfig","id":1}"#; let request = Request::builder().uri("/test").body(Body::from(body)).unwrap(); @@ -296,7 +340,7 @@ mod tests { #[tokio::test] async fn test_api_key_auth_liveness_bypass() { - let layer = ApiKeyAuthLayer::new("test-key".to_string()); + let layer = ApiKeyAuthLayer::new(vec!["test-key".to_string()]); let mut service = layer.layer(MockService); let liveness_body = r#"{"jsonrpc":"2.0","method":"liveness","params":[],"id":1}"#; let request = Request::builder() @@ -446,4 +490,40 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test] + async fn test_api_key_auth_variable_lengths() { + let keys = vec![ + "short".to_string(), + "medium-length-key-20".to_string(), + "very-long-key-50-characters-.......................".to_string(), + ]; + let layer = ApiKeyAuthLayer::new(keys); + let mut service = layer.layer(MockService); + + let request = Request::builder() + .method(Method::POST) + .uri("/test") + .header(X_API_KEY, "medium-length-key-20") + .body(Body::empty()) + .unwrap(); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let id = response + .extensions() + .get::() + .expect("ClientIdentity should be present"); + assert_eq!(id.0, "apikey:091f676a"); + + let request2 = Request::builder() + .method(Method::POST) + .uri("/test") + .header(X_API_KEY, "invalid-key-that-is-somewhat-long-but-wrong") + .body(Body::empty()) + .unwrap(); + + let response2 = service.ready().await.unwrap().call(request2).await.unwrap(); + assert_eq!(response2.status(), StatusCode::UNAUTHORIZED); + } } diff --git a/crates/lib/src/rpc_server/server.rs b/crates/lib/src/rpc_server/server.rs index a0522628..9836e7c7 100644 --- a/crates/lib/src/rpc_server/server.rs +++ b/crates/lib/src/rpc_server/server.rs @@ -1,3 +1,7 @@ +#[cfg(test)] +use crate::tests::usage_limiter_mock::MockUsageTracker as UsageTracker; +#[cfg(not(test))] +use crate::usage_limit::UsageTracker; use crate::{ config::{classify_cors_origins, AuthConfig, CorsOriginsClassification}, constant::{X_API_KEY, X_HMAC_SIGNATURE, X_RECAPTCHA_TOKEN, X_TIMESTAMP}, @@ -9,7 +13,6 @@ use crate::{ recaptcha_util::RecaptchaConfig, rpc::KoraRpc, }, - usage_limit::UsageTracker, }; use crate::state::drain_background_tasks; @@ -186,10 +189,7 @@ pub async fn run_rpc_server(rpc: KoraRpc, port: u16) -> Result Self { - self.config.kora.auth.api_key = Some(api_key); + let mut keys = self.config.kora.auth.api_keys.unwrap_or_default(); + keys.push(api_key); + self.config.kora.auth.api_keys = Some(keys); self } @@ -531,7 +533,7 @@ impl AuthConfigBuilder { pub fn new() -> Self { Self { config: AuthConfig { - api_key: None, + api_keys: None, hmac_secret: None, recaptcha_secret: None, recaptcha_score_threshold: crate::constant::DEFAULT_RECAPTCHA_SCORE_THRESHOLD, @@ -548,8 +550,15 @@ impl AuthConfigBuilder { self.config } + pub fn with_api_keys(mut self, api_keys: Vec) -> Self { + self.config.api_keys = Some(api_keys); + self + } + pub fn with_api_key(mut self, api_key: String) -> Self { - self.config.api_key = Some(api_key); + let mut keys = self.config.api_keys.unwrap_or_default(); + keys.push(api_key); + self.config.api_keys = Some(keys); self } @@ -559,7 +568,9 @@ impl AuthConfigBuilder { } pub fn with_both_auth(mut self, api_key: String, hmac_secret: String) -> Self { - self.config.api_key = Some(api_key); + let mut keys = self.config.api_keys.unwrap_or_default(); + keys.push(api_key); + self.config.api_keys = Some(keys); self.config.hmac_secret = Some(hmac_secret); self } diff --git a/crates/lib/src/tests/mod.rs b/crates/lib/src/tests/mod.rs index 05776809..6ef9cc76 100644 --- a/crates/lib/src/tests/mod.rs +++ b/crates/lib/src/tests/mod.rs @@ -21,3 +21,6 @@ pub mod transaction_mock; #[cfg(test)] pub mod oracle_mock; + +#[cfg(test)] +pub mod usage_limiter_mock; diff --git a/crates/lib/src/tests/usage_limiter_mock.rs b/crates/lib/src/tests/usage_limiter_mock.rs new file mode 100644 index 00000000..214add19 --- /dev/null +++ b/crates/lib/src/tests/usage_limiter_mock.rs @@ -0,0 +1,9 @@ +use crate::error::KoraError; + +pub struct MockUsageTracker; + +impl MockUsageTracker { + pub async fn init_usage_limiter() -> Result<(), KoraError> { + Ok(()) + } +} diff --git a/crates/lib/src/validator/config_validator.rs b/crates/lib/src/validator/config_validator.rs index 3553dd91..3a4586ec 100644 --- a/crates/lib/src/validator/config_validator.rs +++ b/crates/lib/src/validator/config_validator.rs @@ -726,7 +726,7 @@ impl ConfigValidator { warnings.push( "⚠️ SECURITY: Fixed pricing with NO authentication enabled. \ Without authentication, anyone can spam transactions at your expense. \ - Consider enabling api_key or hmac_secret in [kora.auth]." + Consider enabling api_keys or hmac_secret in [kora.auth]." .to_string(), ); } @@ -752,21 +752,17 @@ impl ConfigValidator { let has_auth = config.kora.auth.has_resolved_auth(); if !has_auth { warnings.push( - "⚠️ SECURITY: No authentication configured (neither api_key nor hmac_secret). \ + "⚠️ SECURITY: No authentication configured (neither api_keys nor hmac_secret). \ Authentication is strongly recommended for production deployments. \ - Consider enabling api_key or hmac_secret in [kora.auth]." + Consider enabling api_keys or hmac_secret in [kora.auth]." .to_string(), ); } // The running server resolves auth env-first, so a stale KORA_* environment variable // silently overrides a rotated kora.toml secret and keeps the retired credential valid. - for (env_var, field) in config.kora.auth.env_overridden_fields() { - warnings.push(format!( - "⚠️ SECURITY: environment variable {env_var} overrides {field}. The environment \ - value takes precedence at runtime; if you rotated the secret in kora.toml, the \ - stale environment value is still in effect. Unset {env_var} or align it with the config." - )); + for warning in config.kora.auth.env_overridden_fields() { + warnings.push(warning); } let usage_config = &config.kora.usage_limit; @@ -1115,7 +1111,7 @@ mod tests { validation: validation_config_with_auth(), kora: KoraConfig { auth: AuthConfig { - api_key: Some("rotated-config-key".to_string()), + api_keys: Some(vec!["rotated-config-key".to_string()]), ..Default::default() }, ..KoraConfig::default() diff --git a/examples/getting-started/demo/server/kora.toml b/examples/getting-started/demo/server/kora.toml index 3f06eb27..6058e510 100644 --- a/examples/getting-started/demo/server/kora.toml +++ b/examples/getting-started/demo/server/kora.toml @@ -10,7 +10,8 @@ cors_allow_origins = ["*"] [kora.auth] # Optional: API key for simple authentication -# api_key = "your-api-key" +# api_key = "your-api-key" # Deprecated: use api_keys array instead +# api_keys = ["key-1", "key-2"] # Supports multiple API keys # Optional: HMAC secret for signed requests # hmac_secret = "your-hmac-secret" diff --git a/examples/jito-bundles/server/kora.toml b/examples/jito-bundles/server/kora.toml index 9770d316..b9cb06c3 100644 --- a/examples/jito-bundles/server/kora.toml +++ b/examples/jito-bundles/server/kora.toml @@ -10,7 +10,8 @@ cors_allow_origins = ["*"] [kora.auth] # Optional: API key for simple header-based authentication (use x-api-key header) -api_key = "kora_facilitator_api_key_example" +# api_key = "kora_facilitator_api_key_example" # Deprecated: use api_keys array instead +api_keys = ["kora_facilitator_api_key_example"] # Supports multiple API keys # Optional: HMAC secret for signed requests # hmac_secret = "your-hmac-secret" diff --git a/examples/x402/demo/kora/kora.toml b/examples/x402/demo/kora/kora.toml index ca6b4b20..2aa407cc 100644 --- a/examples/x402/demo/kora/kora.toml +++ b/examples/x402/demo/kora/kora.toml @@ -10,7 +10,8 @@ cors_allow_origins = ["*"] [kora.auth] # Optional: API key for simple header-based authentication (use x-api-key header) -api_key = "kora_facilitator_api_key_example" +# api_key = "kora_facilitator_api_key_example" # Deprecated: use api_keys array instead +api_keys = ["kora_facilitator_api_key_example"] # Supports multiple API keys # Optional: HMAC secret for request signature authentication (use x-hmac-signature and x-timestamp headers) # hmac_secret = "your-hmac-secret" diff --git a/kora.toml b/kora.toml index 2e45debe..6725f64e 100644 --- a/kora.toml +++ b/kora.toml @@ -8,7 +8,8 @@ cors_allow_origins = ["*"] [kora.auth] # Optional: API key for simple authentication -# api_key = "your-api-key" +# api_key = "your-api-key" # Deprecated: use api_keys array instead +# api_keys = ["key-1", "key-2"] # Supports multiple API keys # Optional: HMAC secret for signed requests # hmac_secret = "your-hmac-secret" diff --git a/tests/src/common/fixtures/auth-test.toml b/tests/src/common/fixtures/auth-test.toml index cba05df9..e20857a0 100644 --- a/tests/src/common/fixtures/auth-test.toml +++ b/tests/src/common/fixtures/auth-test.toml @@ -4,7 +4,8 @@ rate_limit = 100 cors_allow_origins = ["*"] [kora.auth] -api_key = "test-api-key-123" +# api_key = "test-api-key-123" # Deprecated: use api_keys array instead +api_keys = ["test-api-key-123"] # Supports multiple API keys hmac_secret = "test-hmac-secret-456" # Cache configuration - disabled for testing