use crate::cli::error::{CliError, CliResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
pub struct SecretManager {
secrets_dir: PathBuf,
cache: HashMap<String, SecretValue>,
key: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretValue {
pub value: String,
pub description: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Copy)]
pub enum SecretBackend {
File,
SystemKeyring,
Environment,
Vault,
}
impl SecretManager {
pub fn new(_backend: SecretBackend) -> CliResult<Self> {
let secrets_dir = Self::get_secrets_dir()?;
Self::with_dir(secrets_dir)
}
pub fn with_dir(secrets_dir: PathBuf) -> CliResult<Self> {
if !secrets_dir.exists() {
fs::create_dir_all(&secrets_dir).map_err(|e| {
CliError::config_error(format!("Cannot create secrets directory: {e}"))
})?;
#[cfg(unix)]
{
let metadata = fs::metadata(&secrets_dir)?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o700);
fs::set_permissions(&secrets_dir, permissions)?;
}
}
Ok(Self {
secrets_dir,
cache: HashMap::new(),
key: None,
})
}
fn get_secrets_dir() -> CliResult<PathBuf> {
if let Ok(dir) = std::env::var("OXIRS_SECRETS_DIR") {
return Ok(PathBuf::from(dir));
}
#[cfg(target_os = "macos")]
let base_dir = dirs::home_dir().map(|h| h.join("Library/Application Support"));
#[cfg(target_os = "linux")]
let base_dir = dirs::config_dir();
#[cfg(target_os = "windows")]
let base_dir = dirs::data_local_dir();
base_dir
.map(|p| p.join("oxirs/secrets"))
.ok_or_else(|| CliError::config_error("Cannot determine secrets directory"))
}
pub fn unlock(&mut self, password: &str) -> CliResult<()> {
self.key = Some(self.derive_key(password)?);
if self.secrets_dir.join(".test").exists() {
self.get_secret(".test")?;
}
Ok(())
}
pub fn is_unlocked(&self) -> bool {
self.key.is_some()
}
pub fn set_secret(
&mut self,
name: &str,
value: &str,
description: Option<String>,
) -> CliResult<()> {
if !self.is_unlocked() {
return Err(CliError::config_error("Secret manager is locked"));
}
let secret = SecretValue {
value: value.to_string(),
description,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
expires_at: None,
};
self.store_encrypted_secret(name, &secret)?;
self.cache.insert(name.to_string(), secret);
Ok(())
}
pub fn get_secret(&mut self, name: &str) -> CliResult<String> {
if let Some(secret) = self.cache.get(name) {
if let Some(expires) = secret.expires_at {
if expires < chrono::Utc::now() {
self.cache.remove(name);
return Err(CliError::config_error("Secret has expired"));
}
}
return Ok(secret.value.clone());
}
let env_name = format!("OXIRS_SECRET_{}", name.to_uppercase().replace('-', "_"));
if let Ok(value) = std::env::var(&env_name) {
return Ok(value);
}
if !self.is_unlocked() {
return Err(CliError::config_error("Secret manager is locked"));
}
let secret = self.load_encrypted_secret(name)?;
if let Some(expires) = secret.expires_at {
if expires < chrono::Utc::now() {
return Err(CliError::config_error("Secret has expired"));
}
}
let value = secret.value.clone();
self.cache.insert(name.to_string(), secret);
Ok(value)
}
pub fn delete_secret(&mut self, name: &str) -> CliResult<()> {
self.cache.remove(name);
let path = self.secrets_dir.join(format!("{name}.secret"));
if path.exists() {
fs::remove_file(path)
.map_err(|e| CliError::config_error(format!("Cannot delete secret: {e}")))?;
}
Ok(())
}
pub fn list_secrets(&self) -> CliResult<Vec<SecretInfo>> {
let mut secrets = Vec::new();
if self.secrets_dir.exists() {
for entry in fs::read_dir(&self.secrets_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
if path.extension().and_then(|e| e.to_str()) == Some("secret")
&& name != ".test"
{
if let Ok(metadata) = self.load_secret_metadata(name) {
secrets.push(metadata);
}
}
}
}
}
for (key, _) in std::env::vars() {
if key.starts_with("OXIRS_SECRET_") {
let name = key
.strip_prefix("OXIRS_SECRET_")
.expect("prefix should match after starts_with check")
.to_lowercase()
.replace('_', "-");
secrets.push(SecretInfo {
name,
description: Some("Environment variable".to_string()),
created_at: None,
expires_at: None,
source: SecretSource::Environment,
});
}
}
secrets.sort_by(|a, b| a.name.cmp(&b.name));
Ok(secrets)
}
fn derive_key(&self, password: &str) -> CliResult<Vec<u8>> {
use ring::pbkdf2;
let salt = b"oxirs-secret-salt"; let mut key = vec![0u8; 32];
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA256,
std::num::NonZeroU32::new(100_000).expect("100_000 is non-zero"),
salt,
password.as_bytes(),
&mut key,
);
Ok(key)
}
fn store_encrypted_secret(&self, name: &str, secret: &SecretValue) -> CliResult<()> {
use ring::aead;
let key = self
.key
.as_ref()
.ok_or_else(|| CliError::config_error("No encryption key available"))?;
let plaintext = serde_json::to_vec(secret)
.map_err(|e| CliError::config_error(format!("Cannot serialize secret: {e}")))?;
let key = aead::UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| CliError::config_error("Invalid encryption key"))?;
let key = aead::LessSafeKey::new(key);
let nonce = aead::Nonce::assume_unique_for_key([0u8; 12]); let mut ciphertext = plaintext.clone();
key.seal_in_place_append_tag(nonce, aead::Aad::empty(), &mut ciphertext)
.map_err(|_| CliError::config_error("Encryption failed"))?;
let path = self.secrets_dir.join(format!("{name}.secret"));
let mut file = File::create(&path)
.map_err(|e| CliError::config_error(format!("Cannot create secret file: {e}")))?;
#[cfg(unix)]
{
let metadata = file.metadata()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
file.set_permissions(permissions)?;
}
file.write_all(&ciphertext)
.map_err(|e| CliError::config_error(format!("Cannot write secret: {e}")))?;
Ok(())
}
fn load_encrypted_secret(&self, name: &str) -> CliResult<SecretValue> {
use ring::aead;
let key = self
.key
.as_ref()
.ok_or_else(|| CliError::config_error("No decryption key available"))?;
let path = self.secrets_dir.join(format!("{name}.secret"));
let mut file = File::open(&path)
.map_err(|_| CliError::config_error(format!("Secret '{name}' not found")))?;
let mut ciphertext = Vec::new();
file.read_to_end(&mut ciphertext)
.map_err(|e| CliError::config_error(format!("Cannot read secret: {e}")))?;
let key = aead::UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| CliError::config_error("Invalid decryption key"))?;
let key = aead::LessSafeKey::new(key);
let nonce = aead::Nonce::assume_unique_for_key([0u8; 12]);
let plaintext = key
.open_in_place(nonce, aead::Aad::empty(), &mut ciphertext)
.map_err(|_| CliError::config_error("Decryption failed - wrong password?"))?;
serde_json::from_slice(plaintext)
.map_err(|e| CliError::config_error(format!("Cannot deserialize secret: {e}")))
}
fn load_secret_metadata(&self, name: &str) -> CliResult<SecretInfo> {
Ok(SecretInfo {
name: name.to_string(),
description: None,
created_at: None,
expires_at: None,
source: SecretSource::File,
})
}
}
#[derive(Debug, Clone)]
pub struct SecretInfo {
pub name: String,
pub description: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub source: SecretSource,
}
#[derive(Debug, Clone, Copy)]
pub enum SecretSource {
File,
Environment,
SystemKeyring,
}
pub mod keyring {
use super::*;
pub fn store_in_keyring(_service: &str, _name: &str, _value: &str) -> CliResult<()> {
Err(CliError::config_error("System keyring not yet implemented"))
}
pub fn get_from_keyring(_service: &str, _name: &str) -> CliResult<String> {
Err(CliError::config_error("System keyring not yet implemented"))
}
}
pub mod credentials {
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointCredentials {
pub url: String,
pub username: Option<String>,
pub password: Option<String>,
pub auth_type: AuthType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthType {
None,
Basic,
Bearer,
OAuth2,
}
pub fn get_endpoint_credentials(
manager: &mut SecretManager,
url: &str,
) -> CliResult<EndpointCredentials> {
let secret_name = format!("endpoint_{}", url.replace(['/', ':'], "_"));
if let Ok(creds_json) = manager.get_secret(&secret_name) {
serde_json::from_str(&creds_json)
.map_err(|e| CliError::config_error(format!("Invalid credentials format: {e}")))
} else {
Ok(EndpointCredentials {
url: url.to_string(),
username: None,
password: None,
auth_type: AuthType::None,
})
}
}
pub fn store_endpoint_credentials(
manager: &mut SecretManager,
creds: &EndpointCredentials,
) -> CliResult<()> {
let secret_name = format!("endpoint_{}", creds.url.replace(['/', ':'], "_"));
let creds_json = serde_json::to_string(creds)
.map_err(|e| CliError::config_error(format!("Cannot serialize credentials: {e}")))?;
manager.set_secret(
&secret_name,
&creds_json,
Some(format!("Credentials for {}", creds.url)),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_secret_manager_creation() {
let dir = tempdir().expect("failed to create temp dir");
let manager = SecretManager::with_dir(dir.path().to_path_buf())
.expect("failed to create SecretManager");
assert!(!manager.is_unlocked());
}
#[test]
fn test_secret_storage_and_retrieval() {
let dir = tempdir().expect("failed to create temp dir");
let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
.expect("failed to create SecretManager");
manager.unlock("test-password").expect("failed to unlock");
manager
.set_secret("test-key", "test-value", Some("Test secret".to_string()))
.expect("failed to set secret");
let value = manager
.get_secret("test-key")
.expect("failed to get secret");
assert_eq!(value, "test-value");
let secrets = manager.list_secrets().expect("failed to list secrets");
assert!(secrets.iter().any(|s| s.name == "test-key"));
}
#[test]
fn test_environment_secret() {
unsafe { std::env::set_var("OXIRS_SECRET_API_KEY", "secret-api-key") };
let dir = tempdir().expect("failed to create temp dir");
let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
.expect("failed to create SecretManager");
let value = manager.get_secret("api-key").expect("failed to get secret");
assert_eq!(value, "secret-api-key");
unsafe { std::env::remove_var("OXIRS_SECRET_API_KEY") };
}
}