use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use secrecy::{ExposeSecret, SecretBox};
use std::path::PathBuf;
use thiserror::Error;
use zeroize::Zeroize;
#[derive(Error, Debug)]
pub enum CredentialError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Crypto error: {0}")]
Crypto(String),
#[error("UTF-8 error: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("Credential not found: {0}")]
NotFound(String),
}
#[derive(Clone)]
pub struct ApiKey(SecretBox<str>);
impl ApiKey {
#[must_use]
pub fn new(key: String) -> Self {
Self(SecretBox::new(key.into_boxed_str()))
}
#[must_use]
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
}
impl std::fmt::Debug for ApiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ApiKey([REDACTED])")
}
}
impl std::fmt::Display for ApiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[REDACTED]")
}
}
pub struct CredentialStore {
encryption_key: SecretBox<[u8; 32]>,
store_path: PathBuf,
}
impl CredentialStore {
#[must_use]
pub fn new(encryption_key: [u8; 32], store_path: PathBuf) -> Self {
Self {
encryption_key: SecretBox::new(Box::new(encryption_key)),
store_path,
}
}
pub fn store(&self, name: &str, credential: &ApiKey) -> Result<(), CredentialError> {
std::fs::create_dir_all(&self.store_path)?;
let encrypted = self.encrypt(credential.expose().as_bytes())?;
let path = self.store_path.join(format!("{name}.enc"));
std::fs::write(&path, &encrypted)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn load(&self, name: &str) -> Result<ApiKey, CredentialError> {
let path = self.store_path.join(format!("{name}.enc"));
if !path.exists() {
return Err(CredentialError::NotFound(name.to_string()));
}
let encrypted = std::fs::read(&path)?;
let mut decrypted = self.decrypt(&encrypted)?;
let key = ApiKey::new(String::from_utf8(decrypted.clone())?);
decrypted.zeroize();
Ok(key)
}
pub fn delete(&self, name: &str) -> Result<(), CredentialError> {
let path = self.store_path.join(format!("{name}.enc"));
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn list(&self) -> Result<Vec<String>, CredentialError> {
if !self.store_path.exists() {
return Ok(vec![]);
}
let mut names = Vec::new();
for entry in std::fs::read_dir(&self.store_path)? {
let entry = entry?;
if let Some(name) = entry.file_name().to_str() {
if let Some(name) = name.strip_suffix(".enc") {
names.push(name.to_string());
}
}
}
Ok(names)
}
fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, CredentialError> {
let cipher = Aes256Gcm::new(self.encryption_key.expose_secret().into());
let nonce_bytes: [u8; 12] = rand::random();
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, data)
.map_err(|e| CredentialError::Crypto(e.to_string()))?;
Ok([nonce_bytes.as_slice(), &ciphertext].concat())
}
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, CredentialError> {
if data.len() < 12 {
return Err(CredentialError::Crypto("Data too short".to_string()));
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let cipher = Aes256Gcm::new(self.encryption_key.expose_secret().into());
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CredentialError::Crypto(e.to_string()))
}
}
#[must_use]
pub fn scrub_secrets(text: &str, patterns: &[&str]) -> String {
let mut result = text.to_string();
for pattern in patterns {
let mut search_start = 0;
while let Some(start) = result[search_start..].find(pattern) {
let abs_start = search_start + start + pattern.len();
let end = result[abs_start..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '&' || c == ',')
.map_or(result.len(), |e| abs_start + e);
result.replace_range(abs_start..end, "[REDACTED]");
search_start = abs_start + "[REDACTED]".len();
}
}
result
}
pub const COMMON_SECRET_PATTERNS: &[&str] = &[
"api_key=",
"apikey=",
"api-key=",
"token=",
"secret=",
"password=",
"Authorization: Bearer ",
"Authorization: Basic ",
"x-api-key: ",
];
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_api_key_redaction() {
let key = ApiKey::new("sk-secret-key-12345".to_string());
assert_eq!(format!("{key:?}"), "ApiKey([REDACTED])");
assert_eq!(format!("{key}"), "[REDACTED]");
assert_eq!(key.expose(), "sk-secret-key-12345");
}
#[test]
fn test_credential_store_roundtrip() {
let temp = tempdir().unwrap();
let encryption_key: [u8; 32] = rand::random();
let store = CredentialStore::new(encryption_key, temp.path().to_path_buf());
let original = ApiKey::new("my-secret-api-key".to_string());
store.store("test-cred", &original).unwrap();
let loaded = store.load("test-cred").unwrap();
assert_eq!(loaded.expose(), "my-secret-api-key");
}
#[test]
fn test_credential_not_found() {
let temp = tempdir().unwrap();
let encryption_key: [u8; 32] = rand::random();
let store = CredentialStore::new(encryption_key, temp.path().to_path_buf());
let result = store.load("nonexistent");
assert!(matches!(result, Err(CredentialError::NotFound(_))));
}
#[test]
fn test_credential_list() {
let temp = tempdir().unwrap();
let encryption_key: [u8; 32] = rand::random();
let store = CredentialStore::new(encryption_key, temp.path().to_path_buf());
store
.store("cred1", &ApiKey::new("value1".to_string()))
.unwrap();
store
.store("cred2", &ApiKey::new("value2".to_string()))
.unwrap();
let names = store.list().unwrap();
assert_eq!(names.len(), 2);
assert!(names.contains(&"cred1".to_string()));
assert!(names.contains(&"cred2".to_string()));
}
#[test]
fn test_scrub_secrets() {
let text = "Error: api_key=sk-12345 failed with token=abc123";
let scrubbed = scrub_secrets(text, &["api_key=", "token="]);
assert_eq!(
scrubbed,
"Error: api_key=[REDACTED] failed with token=[REDACTED]"
);
}
#[test]
fn test_scrub_secrets_with_quotes() {
let text = r#"{"api_key":"sk-secret","other":"value"}"#;
let scrubbed = scrub_secrets(text, &["api_key\":\""]);
assert!(scrubbed.contains("[REDACTED]"));
assert!(!scrubbed.contains("sk-secret"));
}
}