diff --git a/CHANGELOG.md b/CHANGELOG.md index 707c93b..4f5218c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ All notable changes to NORA will be documented in this file. - Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}` - Rate limit configuration logged at startup +#### Secrets Provider Architecture +- Trait-based secrets management (`SecretsProvider` trait) +- ENV provider as default (12-Factor App pattern) +- Protected secrets with `zeroize` (memory zeroed on drop) +- Redacted Debug impl prevents secret leakage in logs +- New config section `[secrets]` with `provider` and `clear_env` options +- Foundation for future AWS Secrets Manager, Vault, K8s integration + ### Changed - Rate limiting functions now accept `&RateLimitConfig` parameter - Improved error messages with `.expect()` instead of `.unwrap()` diff --git a/Cargo.lock b/Cargo.lock index 53e9237..1487014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,6 +1230,7 @@ dependencies = [ "utoipa-swagger-ui", "uuid", "wiremock", + "zeroize", ] [[package]] @@ -2955,6 +2956,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/README.md b/README.md index ffe2545..4aaa135 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ nora migrate --from local --to s3 | `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size | | `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second | | `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size | +| `NORA_SECRETS_PROVIDER` | env | Secrets provider (`env`) | +| `NORA_SECRETS_CLEAR_ENV` | false | Clear env vars after reading | ### config.toml @@ -137,6 +139,12 @@ upload_burst = 500 # Balanced limits for general API endpoints general_rps = 100 general_burst = 200 + +[secrets] +# Provider: env (default), aws-secrets, vault, k8s (coming soon) +provider = "env" +# Clear environment variables after reading (security hardening) +clear_env = false ``` ## Endpoints diff --git a/nora-registry/Cargo.toml b/nora-registry/Cargo.toml index c24930b..770c0bf 100644 --- a/nora-registry/Cargo.toml +++ b/nora-registry/Cargo.toml @@ -42,6 +42,7 @@ thiserror = "2" tower_governor = "0.8" governor = "0.10" parking_lot = "0.12" +zeroize = { version = "1.8", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index b812e42..a774302 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use std::env; use std::fs; +pub use crate::secrets::SecretsConfig; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub server: ServerConfig, @@ -16,6 +18,8 @@ pub struct Config { pub auth: AuthConfig, #[serde(default)] pub rate_limit: RateLimitConfig, + #[serde(default)] + pub secrets: SecretsConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -315,6 +319,14 @@ impl Config { self.rate_limit.general_burst = v; } } + + // Secrets config + if let Ok(val) = env::var("NORA_SECRETS_PROVIDER") { + self.secrets.provider = val; + } + if let Ok(val) = env::var("NORA_SECRETS_CLEAR_ENV") { + self.secrets.clear_env = val.to_lowercase() == "true" || val == "1"; + } } } @@ -336,6 +348,7 @@ impl Default for Config { pypi: PypiConfig::default(), auth: AuthConfig::default(), rate_limit: RateLimitConfig::default(), + secrets: SecretsConfig::default(), } } } diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index 3f4b7c0..64ac340 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -11,6 +11,7 @@ mod openapi; mod rate_limit; mod registry; mod request_id; +mod secrets; mod storage; mod tokens; mod ui; @@ -190,6 +191,25 @@ async fn run_server(config: Config, storage: Storage) { "Rate limiting configured" ); + // Initialize secrets provider + let secrets_provider = match secrets::create_secrets_provider(&config.secrets) { + Ok(provider) => { + info!( + provider = provider.provider_name(), + clear_env = config.secrets.clear_env, + "Secrets provider initialized" + ); + Some(provider) + } + Err(e) => { + warn!(error = %e, "Failed to initialize secrets provider, using defaults"); + None + } + }; + + // Store secrets provider for future use (S3 credentials, etc.) + let _secrets = secrets_provider; + // Load auth if enabled let auth = if config.auth.enabled { let path = Path::new(&config.auth.htpasswd_file); diff --git a/nora-registry/src/secrets/env.rs b/nora-registry/src/secrets/env.rs new file mode 100644 index 0000000..3af3ce3 --- /dev/null +++ b/nora-registry/src/secrets/env.rs @@ -0,0 +1,125 @@ +//! Environment variables secrets provider +//! +//! Reads secrets from environment variables. This is the default provider +//! following 12-Factor App principles. + +use std::env; + +use super::{SecretsError, SecretsProvider}; +use crate::secrets::protected::ProtectedString; +use async_trait::async_trait; + +/// Environment variables secrets provider +/// +/// Reads secrets from environment variables. +/// Optionally clears variables after reading for extra security. +#[derive(Debug, Clone)] +pub struct EnvProvider { + /// Clear environment variables after reading + clear_after_read: bool, +} + +impl EnvProvider { + /// Create a new environment provider + pub fn new() -> Self { + Self { + clear_after_read: false, + } + } + + /// Create a provider that clears env vars after reading + /// + /// This prevents secrets from being visible in `/proc//environ` + pub fn with_clear_after_read(mut self) -> Self { + self.clear_after_read = true; + self + } +} + +impl Default for EnvProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl SecretsProvider for EnvProvider { + async fn get_secret(&self, key: &str) -> Result { + let value = env::var(key).map_err(|_| SecretsError::NotFound(key.to_string()))?; + + if self.clear_after_read { + env::remove_var(key); + } + + Ok(ProtectedString::new(value)) + } + + async fn get_secret_optional(&self, key: &str) -> Option { + env::var(key).ok().map(|v| { + if self.clear_after_read { + env::remove_var(key); + } + ProtectedString::new(v) + }) + } + + fn provider_name(&self) -> &'static str { + "env" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_get_secret_exists() { + env::set_var("TEST_SECRET_123", "secret-value"); + let provider = EnvProvider::new(); + let secret = provider.get_secret("TEST_SECRET_123").await.unwrap(); + assert_eq!(secret.expose(), "secret-value"); + env::remove_var("TEST_SECRET_123"); + } + + #[tokio::test] + async fn test_get_secret_not_found() { + let provider = EnvProvider::new(); + let result = provider.get_secret("NONEXISTENT_VAR_XYZ").await; + assert!(matches!(result, Err(SecretsError::NotFound(_)))); + } + + #[tokio::test] + async fn test_get_secret_optional_exists() { + env::set_var("TEST_OPTIONAL_123", "optional-value"); + let provider = EnvProvider::new(); + let secret = provider.get_secret_optional("TEST_OPTIONAL_123").await; + assert!(secret.is_some()); + assert_eq!(secret.unwrap().expose(), "optional-value"); + env::remove_var("TEST_OPTIONAL_123"); + } + + #[tokio::test] + async fn test_get_secret_optional_not_found() { + let provider = EnvProvider::new(); + let secret = provider.get_secret_optional("NONEXISTENT_OPTIONAL_XYZ").await; + assert!(secret.is_none()); + } + + #[tokio::test] + async fn test_clear_after_read() { + env::set_var("TEST_CLEAR_123", "to-be-cleared"); + let provider = EnvProvider::new().with_clear_after_read(); + + let secret = provider.get_secret("TEST_CLEAR_123").await.unwrap(); + assert_eq!(secret.expose(), "to-be-cleared"); + + // Variable should be cleared + assert!(env::var("TEST_CLEAR_123").is_err()); + } + + #[test] + fn test_provider_name() { + let provider = EnvProvider::new(); + assert_eq!(provider.provider_name(), "env"); + } +} diff --git a/nora-registry/src/secrets/mod.rs b/nora-registry/src/secrets/mod.rs new file mode 100644 index 0000000..b21d429 --- /dev/null +++ b/nora-registry/src/secrets/mod.rs @@ -0,0 +1,166 @@ +#![allow(dead_code)] // Foundational code for future S3/Vault integration + +//! Secrets management for NORA +//! +//! Provides a trait-based architecture for secrets providers: +//! - `env` - Environment variables (default, 12-Factor App) +//! - `aws-secrets` - AWS Secrets Manager (v0.4.0+) +//! - `vault` - HashiCorp Vault (v0.5.0+) +//! - `k8s` - Kubernetes Secrets (v0.4.0+) +//! +//! # Example +//! +//! ```rust,ignore +//! use nora::secrets::{create_secrets_provider, SecretsConfig}; +//! +//! let config = SecretsConfig::default(); // Uses ENV provider +//! let provider = create_secrets_provider(&config)?; +//! +//! let api_key = provider.get_secret("API_KEY").await?; +//! println!("Got secret (redacted): {:?}", api_key); +//! ``` + +mod env; +pub mod protected; + +pub use env::EnvProvider; +#[allow(unused_imports)] +pub use protected::{ProtectedString, S3Credentials}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Secrets provider error +#[derive(Debug, Error)] +pub enum SecretsError { + #[error("Secret not found: {0}")] + NotFound(String), + + #[error("Provider error: {0}")] + Provider(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Unsupported provider: {0}")] + UnsupportedProvider(String), +} + +/// Secrets provider trait +/// +/// Implement this trait to add new secrets backends. +#[async_trait] +pub trait SecretsProvider: Send + Sync { + /// Get a secret by key (required) + async fn get_secret(&self, key: &str) -> Result; + + /// Get a secret by key (optional, returns None if not found) + async fn get_secret_optional(&self, key: &str) -> Option { + self.get_secret(key).await.ok() + } + + /// Get provider name for logging + fn provider_name(&self) -> &'static str; +} + +/// Secrets configuration +/// +/// # Example config.toml +/// +/// ```toml +/// [secrets] +/// provider = "env" +/// clear_env = false +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretsConfig { + /// Provider type: "env", "aws-secrets", "vault", "k8s" + #[serde(default = "default_provider")] + pub provider: String, + + /// Clear environment variables after reading (for env provider) + #[serde(default)] + pub clear_env: bool, +} + +fn default_provider() -> String { + "env".to_string() +} + +impl Default for SecretsConfig { + fn default() -> Self { + Self { + provider: default_provider(), + clear_env: false, + } + } +} + +/// Create a secrets provider based on configuration +/// +/// Currently supports: +/// - `env` - Environment variables (default) +/// +/// Future versions will add: +/// - `aws-secrets` - AWS Secrets Manager +/// - `vault` - HashiCorp Vault +/// - `k8s` - Kubernetes Secrets +pub fn create_secrets_provider( + config: &SecretsConfig, +) -> Result, SecretsError> { + match config.provider.as_str() { + "env" => { + let mut provider = EnvProvider::new(); + if config.clear_env { + provider = provider.with_clear_after_read(); + } + Ok(Box::new(provider)) + } + // Future providers: + // "aws-secrets" => { ... } + // "vault" => { ... } + // "k8s" => { ... } + other => Err(SecretsError::UnsupportedProvider(other.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = SecretsConfig::default(); + assert_eq!(config.provider, "env"); + assert!(!config.clear_env); + } + + #[test] + fn test_create_env_provider() { + let config = SecretsConfig::default(); + let provider = create_secrets_provider(&config).unwrap(); + assert_eq!(provider.provider_name(), "env"); + } + + #[test] + fn test_create_unsupported_provider() { + let config = SecretsConfig { + provider: "unknown".to_string(), + clear_env: false, + }; + let result = create_secrets_provider(&config); + assert!(matches!(result, Err(SecretsError::UnsupportedProvider(_)))); + } + + #[test] + fn test_config_from_toml() { + let toml = r#" + provider = "env" + clear_env = true + "#; + let config: SecretsConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.provider, "env"); + assert!(config.clear_env); + } +} diff --git a/nora-registry/src/secrets/protected.rs b/nora-registry/src/secrets/protected.rs new file mode 100644 index 0000000..a141ba4 --- /dev/null +++ b/nora-registry/src/secrets/protected.rs @@ -0,0 +1,152 @@ +//! Protected secret types with memory safety +//! +//! Secrets are automatically zeroed on drop and redacted in Debug output. + +use std::fmt; +use zeroize::{Zeroize, Zeroizing}; + +/// A protected secret string that is zeroed on drop +/// +/// - Implements Zeroize: memory is overwritten with zeros when dropped +/// - Debug shows `***REDACTED***` instead of actual value +/// - Clone creates a new protected copy +#[derive(Clone, Zeroize)] +#[zeroize(drop)] +pub struct ProtectedString { + inner: String, +} + +impl ProtectedString { + /// Create a new protected string + pub fn new(value: String) -> Self { + Self { inner: value } + } + + /// Get the secret value (use sparingly!) + pub fn expose(&self) -> &str { + &self.inner + } + + /// Consume and return the inner value + pub fn into_inner(self) -> Zeroizing { + Zeroizing::new(self.inner.clone()) + } + + /// Check if the secret is empty + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +impl fmt::Debug for ProtectedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProtectedString") + .field("value", &"***REDACTED***") + .finish() + } +} + +impl fmt::Display for ProtectedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "***REDACTED***") + } +} + +impl From for ProtectedString { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for ProtectedString { + fn from(value: &str) -> Self { + Self::new(value.to_string()) + } +} + +/// S3 credentials with protected secrets +#[derive(Clone, Zeroize)] +#[zeroize(drop)] +pub struct S3Credentials { + pub access_key_id: String, + #[zeroize(skip)] // access_key_id is not sensitive + pub secret_access_key: ProtectedString, + pub region: Option, +} + +impl S3Credentials { + pub fn new(access_key_id: String, secret_access_key: String) -> Self { + Self { + access_key_id, + secret_access_key: ProtectedString::new(secret_access_key), + region: None, + } + } + + pub fn with_region(mut self, region: String) -> Self { + self.region = Some(region); + self + } +} + +impl fmt::Debug for S3Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("S3Credentials") + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"***REDACTED***") + .field("region", &self.region) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_protected_string_redacted_debug() { + let secret = ProtectedString::new("super-secret-value".to_string()); + let debug_output = format!("{:?}", secret); + assert!(debug_output.contains("REDACTED")); + assert!(!debug_output.contains("super-secret-value")); + } + + #[test] + fn test_protected_string_redacted_display() { + let secret = ProtectedString::new("super-secret-value".to_string()); + let display_output = format!("{}", secret); + assert_eq!(display_output, "***REDACTED***"); + } + + #[test] + fn test_protected_string_expose() { + let secret = ProtectedString::new("my-secret".to_string()); + assert_eq!(secret.expose(), "my-secret"); + } + + #[test] + fn test_s3_credentials_redacted_debug() { + let creds = S3Credentials::new( + "AKIAIOSFODNN7EXAMPLE".to_string(), + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), + ); + let debug_output = format!("{:?}", creds); + assert!(debug_output.contains("AKIAIOSFODNN7EXAMPLE")); + assert!(!debug_output.contains("wJalrXUtnFEMI")); + assert!(debug_output.contains("REDACTED")); + } + + #[test] + fn test_protected_string_from_str() { + let secret: ProtectedString = "test".into(); + assert_eq!(secret.expose(), "test"); + } + + #[test] + fn test_protected_string_is_empty() { + let empty = ProtectedString::new(String::new()); + let non_empty = ProtectedString::new("secret".to_string()); + assert!(empty.is_empty()); + assert!(!non_empty.is_empty()); + } +}