mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 13:50:31 +00:00
feat: add secrets provider architecture
Trait-based secrets management for secure credential handling: - SecretsProvider trait for pluggable backends - EnvProvider as default (12-Factor App pattern) - ProtectedString with zeroize (memory zeroed on drop) - Redacted Debug impl prevents secret leakage in logs - S3Credentials struct for future AWS S3 integration - Config: [secrets] section with provider and clear_env options Foundation for AWS Secrets Manager, Vault, K8s (v0.4.0+)
This commit is contained in:
@@ -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}`
|
- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
|
||||||
- Rate limit configuration logged at startup
|
- 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
|
### Changed
|
||||||
- Rate limiting functions now accept `&RateLimitConfig` parameter
|
- Rate limiting functions now accept `&RateLimitConfig` parameter
|
||||||
- Improved error messages with `.expect()` instead of `.unwrap()`
|
- Improved error messages with `.expect()` instead of `.unwrap()`
|
||||||
|
|||||||
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -1230,6 +1230,7 @@ dependencies = [
|
|||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2955,6 +2956,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
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]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ nora migrate --from local --to s3
|
|||||||
| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size |
|
| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size |
|
||||||
| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second |
|
| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second |
|
||||||
| `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size |
|
| `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
|
### config.toml
|
||||||
|
|
||||||
@@ -137,6 +139,12 @@ upload_burst = 500
|
|||||||
# Balanced limits for general API endpoints
|
# Balanced limits for general API endpoints
|
||||||
general_rps = 100
|
general_rps = 100
|
||||||
general_burst = 200
|
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
|
## Endpoints
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ thiserror = "2"
|
|||||||
tower_governor = "0.8"
|
tower_governor = "0.8"
|
||||||
governor = "0.10"
|
governor = "0.10"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
zeroize = { version = "1.8", features = ["derive"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
pub use crate::secrets::SecretsConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -16,6 +18,8 @@ pub struct Config {
|
|||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rate_limit: RateLimitConfig,
|
pub rate_limit: RateLimitConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets: SecretsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -315,6 +319,14 @@ impl Config {
|
|||||||
self.rate_limit.general_burst = v;
|
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(),
|
pypi: PypiConfig::default(),
|
||||||
auth: AuthConfig::default(),
|
auth: AuthConfig::default(),
|
||||||
rate_limit: RateLimitConfig::default(),
|
rate_limit: RateLimitConfig::default(),
|
||||||
|
secrets: SecretsConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod openapi;
|
|||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod request_id;
|
mod request_id;
|
||||||
|
mod secrets;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
mod ui;
|
mod ui;
|
||||||
@@ -190,6 +191,25 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
"Rate limiting configured"
|
"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
|
// Load auth if enabled
|
||||||
let auth = if config.auth.enabled {
|
let auth = if config.auth.enabled {
|
||||||
let path = Path::new(&config.auth.htpasswd_file);
|
let path = Path::new(&config.auth.htpasswd_file);
|
||||||
|
|||||||
125
nora-registry/src/secrets/env.rs
Normal file
125
nora-registry/src/secrets/env.rs
Normal file
@@ -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/<pid>/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<ProtectedString, SecretsError> {
|
||||||
|
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<ProtectedString> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
166
nora-registry/src/secrets/mod.rs
Normal file
166
nora-registry/src/secrets/mod.rs
Normal file
@@ -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<ProtectedString, SecretsError>;
|
||||||
|
|
||||||
|
/// Get a secret by key (optional, returns None if not found)
|
||||||
|
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
||||||
|
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<Box<dyn SecretsProvider>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
nora-registry/src/secrets/protected.rs
Normal file
152
nora-registry/src/secrets/protected.rs
Normal file
@@ -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<String> {
|
||||||
|
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<String> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user