pub mod auditing;
pub mod config;
pub mod file_backend;
pub mod vault_backend;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error, Clone, Serialize, Deserialize)]
pub enum SecretError {
#[error("Secret not found: {key}")]
NotFound { key: String },
#[error("Authentication failed: {message}")]
AuthenticationFailed { message: String },
#[error("Connection error: {message}")]
ConnectionError { message: String },
#[error("Permission denied accessing secret: {key}")]
PermissionDenied { key: String },
#[error("Backend error: {message}")]
BackendError { message: String },
#[error("Configuration error: {message}")]
ConfigurationError { message: String },
#[error("Parse error: {message}")]
ParseError { message: String },
#[error("Operation timed out: {message}")]
Timeout { message: String },
#[error("Invalid secret key format: {key}")]
InvalidKeyFormat { key: String },
#[error("Backend unavailable: {backend}")]
BackendUnavailable { backend: String },
#[error("Rate limit exceeded: {message}")]
RateLimitExceeded { message: String },
#[error("Invalid secret value: {reason}")]
InvalidSecretValue { reason: String },
#[error("Crypto error: {message}")]
CryptoError { message: String },
#[error("IO error: {message}")]
IoError { message: String },
#[error("Operation not supported by backend: {operation}")]
UnsupportedOperation { operation: String },
#[error("Audit logging failed (strict mode): {message}")]
AuditFailed { message: String },
}
#[derive(Clone, Deserialize)]
pub struct Secret {
pub key: String,
pub value: String,
pub metadata: Option<HashMap<String, String>>,
pub created_at: Option<String>,
pub version: Option<String>,
}
impl Serialize for Secret {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("Secret", 5)?;
state.serialize_field("key", &self.key)?;
state.serialize_field("value", "[REDACTED]")?;
state.serialize_field("metadata", &self.metadata)?;
state.serialize_field("created_at", &self.created_at)?;
state.serialize_field("version", &self.version)?;
state.end()
}
}
impl Drop for Secret {
fn drop(&mut self) {
use zeroize::Zeroize;
self.value.zeroize();
}
}
impl Secret {
pub fn new(key: String, value: String) -> Self {
Self {
key,
value,
metadata: None,
created_at: None,
version: None,
}
}
pub fn with_metadata(key: String, value: String, metadata: HashMap<String, String>) -> Self {
Self {
key,
value,
metadata: Some(metadata),
created_at: None,
version: None,
}
}
pub fn value(&self) -> &str {
&self.value
}
pub fn get_metadata(&self, key: &str) -> Option<&String> {
self.metadata.as_ref()?.get(key)
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("key", &self.key)
.field("value", &"[REDACTED]")
.field("metadata", &self.metadata)
.field("created_at", &self.created_at)
.field("version", &self.version)
.finish()
}
}
#[async_trait]
pub trait SecretStore: Send + Sync {
async fn get_secret(&self, key: &str) -> Result<Secret, SecretError>;
async fn list_secrets(&self) -> Result<Vec<String>, SecretError>;
}
#[derive(Debug, Clone)]
pub struct SecretMetadata {
pub created_at: std::time::SystemTime,
pub expires_at: Option<std::time::SystemTime>,
pub rotation_hint: Option<std::time::Duration>,
}
impl SecretMetadata {
pub fn is_expired(&self) -> bool {
if let Some(expires) = self.expires_at {
std::time::SystemTime::now() > expires
} else {
false
}
}
pub fn needs_rotation(&self) -> bool {
if let (Some(expires), Some(hint)) = (self.expires_at, self.rotation_hint) {
if let Ok(remaining) = expires.duration_since(std::time::SystemTime::now()) {
return remaining < hint;
}
}
false
}
}
pub async fn get_secret_checked(
store: &dyn SecretStore,
key: &str,
metadata: Option<&SecretMetadata>,
) -> Result<Secret, SecretError> {
if let Some(meta) = metadata {
if meta.is_expired() {
return Err(SecretError::BackendError {
message: format!("Secret '{key}' has expired"),
});
}
if meta.needs_rotation() {
tracing::warn!(
secret = key,
"Secret is approaching expiry and should be rotated"
);
}
}
store.get_secret(key).await
}
pub type SecretResult<T> = Result<T, SecretError>;
pub async fn resolve_secret_or_env(
env_var: &str,
secret_key: Option<&str>,
store: Option<&(dyn SecretStore + Send + Sync)>,
) -> Option<String> {
if let (Some(store), Some(key)) = (store, secret_key) {
match store.get_secret(key).await {
Ok(secret) => {
tracing::debug!(secret_key = %key, "Resolved secret from secret store");
return Some(secret.value().to_string());
}
Err(e) => {
tracing::warn!(
secret_key = %key,
env_var = %env_var,
error = %e,
"Failed to fetch secret from store; falling back to env var"
);
}
}
}
std::env::var(env_var).ok()
}
pub use auditing::*;
pub use config::*;
pub use file_backend::FileSecretStore;
pub use vault_backend::VaultSecretStore;
pub async fn new_secret_store(
config: &SecretsConfig,
agent_id: &str,
) -> Result<Box<dyn SecretStore + Send + Sync>, SecretError> {
let audit_sink = auditing::create_audit_sink(&config.common.audit);
match &config.backend {
SecretsBackend::File(file_config) => {
let store = FileSecretStore::new(file_config.clone(), audit_sink, agent_id.to_string())
.await
.map_err(|e| SecretError::ConfigurationError {
message: format!("Failed to initialize file backend: {}", e),
})?;
Ok(Box::new(store))
}
SecretsBackend::Vault(vault_config) => {
let store =
VaultSecretStore::new(vault_config.clone(), agent_id.to_string(), audit_sink)
.await
.map_err(|e| SecretError::ConfigurationError {
message: format!("Failed to initialize vault backend: {}", e),
})?;
Ok(Box::new(store))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_creation() {
let secret = Secret::new("test_key".to_string(), "test_value".to_string());
assert_eq!(secret.key, "test_key");
assert_eq!(secret.value(), "test_value");
assert!(secret.metadata.is_none());
}
#[test]
fn test_secret_serialize_redacts_value() {
let secret = Secret::new(
"api_key".to_string(),
"super-secret-plaintext-value".to_string(),
);
let json = serde_json::to_string(&secret).expect("serialize Secret");
assert!(
json.contains("[REDACTED]"),
"Serialize output must contain [REDACTED]; got: {}",
json
);
assert!(
!json.contains("super-secret-plaintext-value"),
"Serialize output must NOT contain the plaintext value; got: {}",
json
);
assert!(json.contains("api_key"));
}
#[test]
fn test_secret_with_metadata() {
let mut metadata = HashMap::new();
metadata.insert("description".to_string(), "Test secret".to_string());
let secret =
Secret::with_metadata("test_key".to_string(), "test_value".to_string(), metadata);
assert_eq!(secret.key, "test_key");
assert_eq!(secret.value(), "test_value");
assert_eq!(
secret.get_metadata("description"),
Some(&"Test secret".to_string())
);
}
#[test]
fn test_secret_error_display() {
let error = SecretError::NotFound {
key: "missing_key".to_string(),
};
assert!(error.to_string().contains("Secret not found: missing_key"));
}
#[test]
fn test_secret_metadata_not_expired() {
let meta = SecretMetadata {
created_at: std::time::SystemTime::now(),
expires_at: Some(std::time::SystemTime::now() + std::time::Duration::from_secs(3600)),
rotation_hint: None,
};
assert!(!meta.is_expired());
}
#[test]
fn test_secret_metadata_expired() {
let meta = SecretMetadata {
created_at: std::time::SystemTime::now() - std::time::Duration::from_secs(7200),
expires_at: Some(std::time::SystemTime::now() - std::time::Duration::from_secs(1)),
rotation_hint: None,
};
assert!(meta.is_expired());
}
#[test]
fn test_secret_metadata_no_expiry() {
let meta = SecretMetadata {
created_at: std::time::SystemTime::now(),
expires_at: None,
rotation_hint: None,
};
assert!(!meta.is_expired());
assert!(!meta.needs_rotation());
}
#[test]
fn test_secret_metadata_needs_rotation() {
let meta = SecretMetadata {
created_at: std::time::SystemTime::now() - std::time::Duration::from_secs(3600),
expires_at: Some(std::time::SystemTime::now() + std::time::Duration::from_secs(300)),
rotation_hint: Some(std::time::Duration::from_secs(600)),
};
assert!(!meta.is_expired());
assert!(meta.needs_rotation());
}
#[test]
fn test_secret_metadata_no_rotation_needed() {
let meta = SecretMetadata {
created_at: std::time::SystemTime::now(),
expires_at: Some(std::time::SystemTime::now() + std::time::Duration::from_secs(7200)),
rotation_hint: Some(std::time::Duration::from_secs(600)),
};
assert!(!meta.is_expired());
assert!(!meta.needs_rotation());
}
struct MapStore {
data: HashMap<String, String>,
fail: bool,
}
#[async_trait]
impl SecretStore for MapStore {
async fn get_secret(&self, key: &str) -> Result<Secret, SecretError> {
if self.fail {
return Err(SecretError::BackendError {
message: "synthetic failure".to_string(),
});
}
self.data
.get(key)
.map(|v| Secret::new(key.to_string(), v.clone()))
.ok_or_else(|| SecretError::NotFound {
key: key.to_string(),
})
}
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
Ok(self.data.keys().cloned().collect())
}
}
#[tokio::test]
async fn resolve_returns_store_value_when_present() {
std::env::set_var("RESOLVE_TEST_KEY_A", "env-key");
let store = MapStore {
data: HashMap::from([("llm/anthropic".to_string(), "vault-key".to_string())]),
fail: false,
};
let got =
resolve_secret_or_env("RESOLVE_TEST_KEY_A", Some("llm/anthropic"), Some(&store)).await;
std::env::remove_var("RESOLVE_TEST_KEY_A");
assert_eq!(got.as_deref(), Some("vault-key"));
}
#[tokio::test]
async fn resolve_falls_back_to_env_on_store_error() {
std::env::set_var("RESOLVE_TEST_KEY_B", "env-key");
let store = MapStore {
data: HashMap::new(),
fail: true,
};
let got =
resolve_secret_or_env("RESOLVE_TEST_KEY_B", Some("llm/anthropic"), Some(&store)).await;
std::env::remove_var("RESOLVE_TEST_KEY_B");
assert_eq!(got.as_deref(), Some("env-key"));
}
#[tokio::test]
async fn resolve_uses_env_when_no_secret_key() {
std::env::set_var("RESOLVE_TEST_KEY_C", "env-only");
let got = resolve_secret_or_env("RESOLVE_TEST_KEY_C", None, None).await;
std::env::remove_var("RESOLVE_TEST_KEY_C");
assert_eq!(got.as_deref(), Some("env-only"));
}
#[tokio::test]
async fn resolve_returns_none_when_nothing_set() {
std::env::remove_var("RESOLVE_TEST_KEY_D");
let got = resolve_secret_or_env("RESOLVE_TEST_KEY_D", None, None).await;
assert!(got.is_none());
}
}