refactor: update dependencies and remove unused code
- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
@@ -12,6 +14,27 @@ use uuid::Uuid;
|
||||
/// Prefix to identify encrypted files (magic bytes)
|
||||
const ENCRYPTED_MAGIC: &[u8] = b"ARCHI_ENC1";
|
||||
|
||||
/// Metadata for a stored secret (stored alongside the encrypted data).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretMetadata {
|
||||
pub secret_id: String,
|
||||
pub key: String,
|
||||
pub app_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub rotated_at: Option<DateTime<Utc>>,
|
||||
pub rotation_count: u32,
|
||||
}
|
||||
|
||||
/// Info about a secret that may need rotation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExpiringSecret {
|
||||
pub secret_id: String,
|
||||
pub key: String,
|
||||
pub app_id: String,
|
||||
pub age_days: i64,
|
||||
pub last_rotated: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct SecretsManager {
|
||||
secrets_dir: PathBuf,
|
||||
cipher: Aes256Gcm,
|
||||
@@ -77,7 +100,7 @@ impl SecretsManager {
|
||||
pub async fn store_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
_key: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<String> {
|
||||
let secret_id = Uuid::new_v4().to_string();
|
||||
@@ -95,6 +118,25 @@ impl SecretsManager {
|
||||
.await
|
||||
.context("Failed to write secret")?;
|
||||
|
||||
// Save metadata
|
||||
let metadata = SecretMetadata {
|
||||
secret_id: secret_id.clone(),
|
||||
key: key.to_string(),
|
||||
app_id: app_id.to_string(),
|
||||
created_at: Utc::now(),
|
||||
rotated_at: None,
|
||||
rotation_count: 0,
|
||||
};
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
let meta_json = serde_json::to_string(&metadata)
|
||||
.context("Failed to serialize metadata")?;
|
||||
fs::write(&meta_path, meta_json.as_bytes())
|
||||
.await
|
||||
.context("Failed to write metadata")?;
|
||||
|
||||
// Set restrictive permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -102,6 +144,9 @@ impl SecretsManager {
|
||||
let mut perms = fs::metadata(&secret_path).await?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&secret_path, perms).await?;
|
||||
let mut meta_perms = fs::metadata(&meta_path).await?.permissions();
|
||||
meta_perms.set_mode(0o600);
|
||||
fs::set_permissions(&meta_path, meta_perms).await?;
|
||||
}
|
||||
|
||||
Ok(secret_id)
|
||||
@@ -164,16 +209,152 @@ impl SecretsManager {
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
/// Delete a secret
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
/// Rotate a secret: generate a new random value, re-encrypt, update metadata.
|
||||
/// Returns the new plaintext secret value.
|
||||
pub async fn rotate_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
secret_id: &str,
|
||||
) -> Result<String> {
|
||||
// Generate a new random secret (32 bytes, hex-encoded = 64 chars)
|
||||
let mut new_secret_bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut new_secret_bytes);
|
||||
let new_value = hex::encode(new_secret_bytes);
|
||||
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
anyhow::ensure!(
|
||||
secret_path.exists(),
|
||||
"Secret {} not found for app {}",
|
||||
secret_id,
|
||||
app_id
|
||||
);
|
||||
|
||||
// Re-encrypt with new value
|
||||
let encrypted = self
|
||||
.encrypt(new_value.as_bytes())
|
||||
.context("Failed to encrypt rotated secret")?;
|
||||
fs::write(&secret_path, &encrypted)
|
||||
.await
|
||||
.context("Failed to write rotated secret")?;
|
||||
|
||||
// Update metadata
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
if meta_path.exists() {
|
||||
if let Ok(data) = fs::read_to_string(&meta_path).await {
|
||||
if let Ok(mut metadata) = serde_json::from_str::<SecretMetadata>(&data) {
|
||||
metadata.rotated_at = Some(Utc::now());
|
||||
metadata.rotation_count += 1;
|
||||
if let Ok(json) = serde_json::to_string(&metadata) {
|
||||
let _ = fs::write(&meta_path, json.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_value)
|
||||
}
|
||||
|
||||
/// List secrets older than `max_age_days` that may need rotation.
|
||||
pub async fn list_expiring(
|
||||
&self,
|
||||
max_age_days: i64,
|
||||
) -> Result<Vec<ExpiringSecret>> {
|
||||
let mut expiring = Vec::new();
|
||||
let now = Utc::now();
|
||||
|
||||
if !self.secrets_dir.exists() {
|
||||
return Ok(expiring);
|
||||
}
|
||||
|
||||
let mut app_dirs = fs::read_dir(&self.secrets_dir).await?;
|
||||
while let Some(app_entry) = app_dirs.next_entry().await? {
|
||||
let app_path = app_entry.path();
|
||||
if !app_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_id = app_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let mut entries = fs::read_dir(&app_path).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if !name.ends_with(".meta.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(data) = fs::read_to_string(&path).await {
|
||||
if let Ok(metadata) = serde_json::from_str::<SecretMetadata>(&data) {
|
||||
let reference_time =
|
||||
metadata.rotated_at.unwrap_or(metadata.created_at);
|
||||
let age = now.signed_duration_since(reference_time);
|
||||
if age.num_days() >= max_age_days {
|
||||
expiring.push(ExpiringSecret {
|
||||
secret_id: metadata.secret_id,
|
||||
key: metadata.key,
|
||||
app_id: metadata.app_id,
|
||||
age_days: age.num_days(),
|
||||
last_rotated: metadata.rotated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(expiring)
|
||||
}
|
||||
|
||||
/// Read metadata for a specific secret.
|
||||
pub async fn get_metadata(
|
||||
&self,
|
||||
app_id: &str,
|
||||
secret_id: &str,
|
||||
) -> Result<Option<SecretMetadata>> {
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
|
||||
if !meta_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(&meta_path)
|
||||
.await
|
||||
.context("Failed to read metadata")?;
|
||||
let metadata: SecretMetadata =
|
||||
serde_json::from_str(&data).context("Failed to parse metadata")?;
|
||||
Ok(Some(metadata))
|
||||
}
|
||||
|
||||
/// Delete a secret and its metadata
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
let secret_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
let meta_path = self
|
||||
.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.meta.json", secret_id));
|
||||
|
||||
if secret_path.exists() {
|
||||
fs::remove_file(&secret_path).await?;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
fs::remove_file(&meta_path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -256,4 +437,113 @@ mod tests {
|
||||
// File must start with our magic prefix
|
||||
assert_eq!(&raw[..ENCRYPTED_MAGIC.len()], ENCRYPTED_MAGIC);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_secret() {
|
||||
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", "api-key", "original_value")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let original = mgr.read_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_eq!(original, "original_value");
|
||||
|
||||
let new_value = mgr.rotate_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_ne!(new_value, "original_value");
|
||||
assert_eq!(new_value.len(), 64); // 32 bytes hex-encoded
|
||||
|
||||
let read_back = mgr.read_secret("test-app", &secret_id).await.unwrap();
|
||||
assert_eq!(read_back, new_value);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_updates_metadata() {
|
||||
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-pass", "initial")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meta_before = mgr.get_metadata("test-app", &secret_id).await.unwrap().unwrap();
|
||||
assert_eq!(meta_before.rotation_count, 0);
|
||||
assert!(meta_before.rotated_at.is_none());
|
||||
|
||||
mgr.rotate_secret("test-app", &secret_id).await.unwrap();
|
||||
|
||||
let meta_after = mgr.get_metadata("test-app", &secret_id).await.unwrap().unwrap();
|
||||
assert_eq!(meta_after.rotation_count, 1);
|
||||
assert!(meta_after.rotated_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_nonexistent_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let result = mgr.rotate_secret("test-app", "nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_expiring_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let expiring = mgr.list_expiring(90).await.unwrap();
|
||||
assert!(expiring.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_expiring_fresh_secrets_not_listed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
mgr.store_secret("test-app", "key1", "val1").await.unwrap();
|
||||
|
||||
// Fresh secret (0 days old) should not be listed for 90-day expiry
|
||||
let expiring = mgr.list_expiring(90).await.unwrap();
|
||||
assert!(expiring.is_empty());
|
||||
|
||||
// But it should appear for 0-day threshold
|
||||
let expiring = mgr.list_expiring(0).await.unwrap();
|
||||
assert_eq!(expiring.len(), 1);
|
||||
assert_eq!(expiring[0].key, "key1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metadata_stored_and_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = SecretsManager::new(dir.path().to_path_buf(), test_key()).unwrap();
|
||||
|
||||
let secret_id = mgr
|
||||
.store_secret("myapp", "connection-string", "postgres://...")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let meta = mgr.get_metadata("myapp", &secret_id).await.unwrap().unwrap();
|
||||
assert_eq!(meta.key, "connection-string");
|
||||
assert_eq!(meta.app_id, "myapp");
|
||||
assert_eq!(meta.rotation_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_removes_metadata() {
|
||||
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", "val")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mgr.delete_secret("test-app", &secret_id).await.unwrap();
|
||||
|
||||
let meta = mgr.get_metadata("test-app", &secret_id).await.unwrap();
|
||||
assert!(meta.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user