use anyhow::{anyhow, Context, Result};
use secrecy::{ExposeSecret, Secret};
use std::fmt;
use zeroize::Zeroizing;
#[derive(Clone)]
pub struct SecureApiConfig {
api_key: Option<Secret<String>>,
pub endpoint: String,
pub model: String,
pub provider: String,
}
impl SecureApiConfig {
pub fn new(api_key: String, endpoint: String, model: String, provider: String) -> Self {
Self {
api_key: Some(Secret::new(api_key)),
endpoint,
model,
provider,
}
}
pub fn without_key(endpoint: String, model: String, provider: String) -> Self {
Self {
api_key: None,
endpoint,
model,
provider,
}
}
pub fn has_api_key(&self) -> bool {
self.api_key.is_some()
}
pub fn get_api_key(&self) -> Option<&str> {
self.api_key.as_ref().map(|s| s.expose_secret().as_str())
}
pub fn create_auth_header(&self) -> Option<Zeroizing<String>> {
self.api_key
.as_ref()
.map(|key| Zeroizing::new(format!("Bearer {}", key.expose_secret())))
}
pub fn from_env(
env_var: &str,
endpoint: String,
model: String,
provider: String,
) -> Result<Self> {
let api_key = std::env::var(env_var).with_context(|| {
format!(
"{} environment variable not set.\n\
For secure storage, use: ae keys store {} <your-key>",
env_var, provider
)
})?;
eprintln!(
"[SECURITY WARNING] API key loaded from environment variable '{}'.\n\
Environment variables are visible to all processes.\n\
Recommend migrating to OS credential store with: ae keys store {} <key>",
env_var, provider
);
Ok(Self::new(api_key, endpoint, model, provider))
}
pub fn from_keyring(
service: &str,
endpoint: String,
model: String,
provider: String,
) -> Result<Self> {
use keyring::Entry;
let entry =
Entry::new(service, "aethershell").context("Failed to access OS credential store")?;
let api_key = entry.get_password().with_context(|| {
format!(
"API key not found in credential store for service '{}'.\n\
Store it with: ae keys store {} <your-key>",
service, provider
)
})?;
Ok(Self::new(api_key, endpoint, model, provider))
}
pub fn from_keyring_or_env(
service: &str,
env_var: &str,
endpoint: String,
model: String,
provider: String,
) -> Result<Self> {
match Self::from_keyring(service, endpoint.clone(), model.clone(), provider.clone()) {
Ok(config) => {
eprintln!("[SECURITY] Using API key from OS credential store");
Ok(config)
}
Err(_) => {
eprintln!(
"[SECURITY] OS credential store not available, falling back to environment variable"
);
Self::from_env(env_var, endpoint, model, provider)
}
}
}
pub fn store_in_keyring(service: &str, api_key: &str) -> Result<()> {
use keyring::Entry;
let entry =
Entry::new(service, "aethershell").context("Failed to access OS credential store")?;
entry
.set_password(api_key)
.context("Failed to store API key in credential store")?;
eprintln!(
"[SECURITY] API key securely stored in OS credential store for service '{}'",
service
);
Ok(())
}
pub fn delete_from_keyring(service: &str) -> Result<()> {
use keyring::Entry;
let entry =
Entry::new(service, "aethershell").context("Failed to access OS credential store")?;
entry
.delete_password()
.context("Failed to delete API key from credential store")?;
eprintln!(
"[SECURITY] API key deleted from OS credential store for service '{}'",
service
);
Ok(())
}
pub fn validate_format(&self) -> Result<()> {
if let Some(key) = &self.api_key {
let key_str = key.expose_secret();
if key_str.is_empty() {
return Err(anyhow!("API key is empty"));
}
if key_str.contains('\0') {
return Err(anyhow!("API key contains null byte"));
}
match self.provider.as_str() {
"openai" => {
if !key_str.starts_with("sk-") && !key_str.starts_with("sk-proj-") {
return Err(anyhow!(
"OpenAI API key should start with 'sk-' or 'sk-proj-'"
));
}
if key_str.len() < 20 || key_str.len() > 200 {
return Err(anyhow!("OpenAI API key length is suspicious"));
}
}
"anthropic" => {
if !key_str.starts_with("sk-ant-") {
return Err(anyhow!("Anthropic API key should start with 'sk-ant-'"));
}
}
_ => {
if key_str.len() < 10 {
return Err(anyhow!("API key is too short"));
}
if key_str.len() > 500 {
return Err(anyhow!("API key is too long"));
}
}
}
Ok(())
} else {
Err(anyhow!("No API key configured"))
}
}
}
impl fmt::Debug for SecureApiConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecureApiConfig")
.field("api_key", &"<REDACTED>")
.field("endpoint", &self.endpoint)
.field("model", &self.model)
.field("provider", &self.provider)
.finish()
}
}
impl Drop for SecureApiConfig {
fn drop(&mut self) {
self.api_key = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secure_config_creation() {
let config = SecureApiConfig::new(
"sk-test123".to_string(),
"https://api.openai.com".to_string(),
"gpt-4".to_string(),
"openai".to_string(),
);
assert!(config.has_api_key());
assert_eq!(config.endpoint, "https://api.openai.com");
assert_eq!(config.model, "gpt-4");
}
#[test]
fn test_no_key_exposure_in_debug() {
let config = SecureApiConfig::new(
"sk-secret123".to_string(),
"https://api.openai.com".to_string(),
"gpt-4".to_string(),
"openai".to_string(),
);
let debug_output = format!("{:?}", config);
assert!(!debug_output.contains("sk-secret"));
assert!(debug_output.contains("<REDACTED>"));
}
#[test]
fn test_auth_header_creation() {
let config = SecureApiConfig::new(
"sk-test123".to_string(),
"https://api.openai.com".to_string(),
"gpt-4".to_string(),
"openai".to_string(),
);
let header = config.create_auth_header().unwrap();
assert_eq!(*header, "Bearer sk-test123");
drop(header);
}
#[test]
fn test_openai_key_validation() {
let config = SecureApiConfig::new(
"sk-proj-test123456789012345".to_string(),
"https://api.openai.com".to_string(),
"gpt-4".to_string(),
"openai".to_string(),
);
assert!(config.validate_format().is_ok());
}
#[test]
fn test_invalid_openai_key() {
let config = SecureApiConfig::new(
"invalid-key".to_string(),
"https://api.openai.com".to_string(),
"gpt-4".to_string(),
"openai".to_string(),
);
assert!(config.validate_format().is_err());
}
#[test]
fn test_config_without_key() {
let config = SecureApiConfig::without_key(
"http://localhost:11434".to_string(),
"llama3".to_string(),
"ollama".to_string(),
);
assert!(!config.has_api_key());
assert!(config.get_api_key().is_none());
}
}