archy/core/security/src/secrets_manager.rs

260 lines
8.3 KiB
Rust
Raw Normal View History

2026-01-24 22:01:51 +00:00
// Encrypted secrets management for containers
// Stores secrets encrypted with AES-256-GCM and injects them at runtime
2026-01-24 22:01:51 +00:00
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
2026-01-24 22:01:51 +00:00
use anyhow::{Context, Result};
use rand::RngCore;
2026-01-24 22:01:51 +00:00
use std::path::PathBuf;
use tokio::fs;
use uuid::Uuid;
/// Prefix to identify encrypted files (magic bytes)
const ENCRYPTED_MAGIC: &[u8] = b"ARCHI_ENC1";
2026-01-24 22:01:51 +00:00
pub struct SecretsManager {
secrets_dir: PathBuf,
cipher: Aes256Gcm,
2026-01-24 22:01:51 +00:00
}
impl SecretsManager {
/// Create a new SecretsManager with a 32-byte encryption key.
/// In production, derive this key from the user's password via Argon2.
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Result<Self> {
anyhow::ensure!(
encryption_key.len() == 32,
"Encryption key must be exactly 32 bytes (256 bits), got {}",
encryption_key.len()
);
let cipher = Aes256Gcm::new_from_slice(&encryption_key)
.map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;
Ok(Self {
2026-01-24 22:01:51 +00:00
secrets_dir,
cipher,
})
}
/// Encrypt a plaintext value using AES-256-GCM.
/// Returns: MAGIC (10 bytes) + nonce (12 bytes) + ciphertext (variable)
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, plaintext)
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + 12 + ciphertext.len());
output.extend_from_slice(ENCRYPTED_MAGIC);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
2026-01-24 22:01:51 +00:00
}
/// Decrypt a previously encrypted value.
fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
let magic_len = ENCRYPTED_MAGIC.len();
anyhow::ensure!(
data.len() > magic_len + 12,
"Encrypted data too short"
);
anyhow::ensure!(
&data[..magic_len] == ENCRYPTED_MAGIC,
"Invalid encrypted data (bad magic bytes)"
);
let nonce = Nonce::from_slice(&data[magic_len..magic_len + 12]);
let ciphertext = &data[magic_len + 12..];
self.cipher
.decrypt(nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("Decryption failed (wrong key or corrupted data): {}", e))
}
/// Store a secret for an app (encrypted at rest)
2026-01-24 22:01:51 +00:00
pub async fn store_secret(
&self,
app_id: &str,
_key: &str,
2026-01-24 22:01:51 +00:00
value: &str,
) -> Result<String> {
let secret_id = Uuid::new_v4().to_string();
let secret_path = self
.secrets_dir
2026-01-24 22:01:51 +00:00
.join(app_id)
.join(format!("{}.secret", secret_id));
2026-01-24 22:01:51 +00:00
fs::create_dir_all(secret_path.parent().unwrap()).await?;
let encrypted = self
.encrypt(value.as_bytes())
.context("Failed to encrypt secret")?;
fs::write(&secret_path, &encrypted)
.await
2026-01-24 22:01:51 +00:00
.context("Failed to write secret")?;
2026-01-24 22:01:51 +00:00
// Set restrictive permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&secret_path).await?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&secret_path, perms).await?;
}
2026-01-24 22:01:51 +00:00
Ok(secret_id)
}
/// Read and decrypt a secret value
pub async fn read_secret(&self, app_id: &str, secret_id: &str) -> Result<String> {
let secret_path = self
.secrets_dir
.join(app_id)
.join(format!("{}.secret", secret_id));
let data = fs::read(&secret_path)
.await
.context("Failed to read secret file")?;
// Support reading legacy plaintext secrets (no magic prefix)
if data.len() < ENCRYPTED_MAGIC.len()
|| &data[..ENCRYPTED_MAGIC.len()] != ENCRYPTED_MAGIC
{
return String::from_utf8(data)
.context("Legacy secret is not valid UTF-8");
}
let plaintext = self.decrypt(&data)?;
String::from_utf8(plaintext).context("Decrypted secret is not valid UTF-8")
}
2026-01-24 22:01:51 +00:00
/// Retrieve a secret (returns the secret ID path for volume mounting)
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
self.secrets_dir
.join(app_id)
.join(format!("{}.secret", secret_id))
}
2026-01-24 22:01:51 +00:00
/// List secrets for an app
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
let app_secrets_dir = self.secrets_dir.join(app_id);
2026-01-24 22:01:51 +00:00
if !app_secrets_dir.exists() {
return Ok(vec![]);
}
2026-01-24 22:01:51 +00:00
let mut secrets = Vec::new();
let mut entries = fs::read_dir(&app_secrets_dir).await?;
2026-01-24 22:01:51 +00:00
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
if let Some(secret_id) = path
.file_stem()
2026-01-24 22:01:51 +00:00
.and_then(|s| s.to_str())
.map(|s| s.to_string())
{
2026-01-24 22:01:51 +00:00
secrets.push(secret_id);
}
}
}
2026-01-24 22:01:51 +00:00
Ok(secrets)
}
2026-01-24 22:01:51 +00:00
/// Delete a secret
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
let secret_path = self
.secrets_dir
2026-01-24 22:01:51 +00:00
.join(app_id)
.join(format!("{}.secret", secret_id));
2026-01-24 22:01:51 +00:00
if secret_path.exists() {
fs::remove_file(&secret_path).await?;
}
2026-01-24 22:01:51 +00:00
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> Vec<u8> {
vec![0x42; 32] // 32-byte test key
}
#[tokio::test]
async fn test_encrypt_decrypt_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
let secret_id = mgr
.store_secret("test-app", "db-password", "supersecret123")
.await
.unwrap();
let decrypted = mgr.read_secret("test-app", &secret_id).await.unwrap();
assert_eq!(decrypted, "supersecret123");
}
#[tokio::test]
async fn test_wrong_key_fails() {
let dir = tempfile::tempdir().unwrap();
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
let secret_id = mgr
.store_secret("test-app", "key", "secret")
.await
.unwrap();
let wrong_key = vec![0x99; 32];
let mgr2 = SecretsManager::new(dir.path().to_path_buf(), wrong_key).unwrap();
assert!(mgr2.read_secret("test-app", &secret_id).await.is_err());
}
#[tokio::test]
async fn test_list_and_delete() {
let dir = tempfile::tempdir().unwrap();
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
let id1 = mgr.store_secret("app1", "k1", "v1").await.unwrap();
let _id2 = mgr.store_secret("app1", "k2", "v2").await.unwrap();
let list = mgr.list_secrets("app1").await.unwrap();
assert_eq!(list.len(), 2);
mgr.delete_secret("app1", &id1).await.unwrap();
let list = mgr.list_secrets("app1").await.unwrap();
assert_eq!(list.len(), 1);
}
#[test]
fn test_invalid_key_length() {
let dir = tempfile::tempdir().unwrap();
assert!(SecretsManager::new(dir.path().to_path_buf(), vec![0; 16]).is_err());
}
#[tokio::test]
async fn test_file_is_encrypted_on_disk() {
let dir = tempfile::tempdir().unwrap();
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
let secret_id = mgr
.store_secret("test-app", "key", "my_secret_value")
.await
.unwrap();
let path = mgr.get_secret_path("test-app", &secret_id);
let raw = std::fs::read(&path).unwrap();
// File must NOT contain plaintext
assert!(!raw.windows(15).any(|w| w == b"my_secret_value"));
// File must start with our magic prefix
assert_eq!(&raw[..ENCRYPTED_MAGIC.len()], ENCRYPTED_MAGIC);
}
}