//! Central encrypted secret storage for provider/ACME credentials. //! Plaintext exists only for the duration of an explicit read/write operation. use anyhow::{ensure, Context, Result}; use rand::{rngs::OsRng, RngCore}; use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM}; use serde::Serialize; use sqlx::Row; use std::{ fs::{self, OpenOptions}, io::{Read, Write}, path::{Path, PathBuf}, }; use zeroize::{Zeroize, Zeroizing}; const KEY_BYTES: usize = 32; const NONCE_BYTES: usize = 12; const ENCRYPTION_VERSION: i64 = 1; const MAX_SECRET_BYTES: usize = 64 * 1024; #[derive(Debug, Clone, Serialize)] pub struct SecretMetadata { pub secret_id: String, pub provider: String, pub encryption_version: i64, pub metadata: serde_json::Value, pub created_at: i64, pub updated_at: i64, } fn secrets_dir(data_dir: &Path) -> PathBuf { data_dir.join("secrets") } pub fn master_key_path(data_dir: &Path) -> PathBuf { secrets_dir(data_dir).join("master.key") } fn private_directory(path: &Path) -> Result<()> { if !path.exists() { fs::create_dir_all(path)?; } let metadata = fs::symlink_metadata(path)?; ensure!( metadata.is_dir() && !metadata.file_type().is_symlink(), "secret store directory must be a real directory" ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if metadata.permissions().mode() & 0o077 != 0 { fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; } } Ok(()) } fn read_key(path: &Path) -> Result> { let metadata = fs::symlink_metadata(path).context("secret store master key is missing")?; ensure!( metadata.is_file() && !metadata.file_type().is_symlink(), "secret store master key must be a regular file" ); ensure!(metadata.len() == KEY_BYTES as u64, "invalid secret store master key size"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; ensure!( metadata.permissions().mode() & 0o077 == 0, "secret store master key must have mode 0600" ); } let mut bytes = Zeroizing::new([0u8; KEY_BYTES]); let mut options = OpenOptions::new(); options.read(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); } let mut file = options.open(path)?; file.read_exact(bytes.as_mut())?; let mut extra = [0u8; 1]; ensure!(file.read(&mut extra)? == 0, "invalid secret store master key size"); Ok(bytes) } pub fn initialize(data_dir: &Path) -> Result<()> { let directory = secrets_dir(data_dir); private_directory(&directory)?; let path = master_key_path(data_dir); if path.exists() { let _ = read_key(&path)?; return Ok(()); } let mut key = Zeroizing::new([0u8; KEY_BYTES]); OsRng.fill_bytes(key.as_mut()); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } match options.open(&path) { Ok(mut file) => { file.write_all(key.as_ref())?; file.sync_all()?; } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { let _ = read_key(&path)?; } Err(error) => return Err(error.into()), } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; } Ok(()) } fn validate_identity(secret_id: &str, provider: &str) -> Result<()> { ensure!( !secret_id.is_empty() && secret_id.len() <= 256 && secret_id .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'_' | b'-' | b'.')), "invalid secret id" ); ensure!( !provider.is_empty() && provider.len() <= 64 && provider .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')), "invalid secret provider" ); Ok(()) } fn aad(secret_id: &str, provider: &str) -> Vec { format!("rustproxy-secret-v{ENCRYPTION_VERSION}\0{secret_id}\0{provider}").into_bytes() } fn key(data_dir: &Path) -> Result { let bytes = read_key(&master_key_path(data_dir))?; let unbound = UnboundKey::new(&AES_256_GCM, bytes.as_ref()) .map_err(|_| anyhow::anyhow!("cannot initialize secret store cipher"))?; Ok(LessSafeKey::new(unbound)) } pub async fn put( store: &crate::db::Store, data_dir: &Path, secret_id: &str, provider: &str, plaintext: &[u8], metadata: serde_json::Value, ) -> Result<()> { validate_identity(secret_id, provider)?; ensure!(!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, "invalid secret size"); let metadata = serde_json::to_string(&metadata)?; ensure!(metadata.len() <= 4096, "secret metadata is too large"); initialize(data_dir)?; let cipher = key(data_dir)?; let mut nonce = [0u8; NONCE_BYTES]; OsRng.fill_bytes(&mut nonce); let mut ciphertext = Zeroizing::new(plaintext.to_vec()); cipher .seal_in_place_append_tag( Nonce::assume_unique_for_key(nonce), Aad::from(aad(secret_id, provider)), &mut *ciphertext, ) .map_err(|_| anyhow::anyhow!("secret encryption failed"))?; let now = crate::auth::now(); sqlx::query( "INSERT INTO secret_store(secret_id, provider, encryption_version, nonce, ciphertext, metadata, created_at, updated_at) \ VALUES(?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(secret_id) DO UPDATE SET provider=excluded.provider, encryption_version=excluded.encryption_version, \ nonce=excluded.nonce, ciphertext=excluded.ciphertext, metadata=excluded.metadata, updated_at=excluded.updated_at", ) .bind(secret_id) .bind(provider) .bind(ENCRYPTION_VERSION) .bind(nonce.to_vec()) .bind(ciphertext.as_slice()) .bind(metadata) .bind(now) .bind(now) .execute(&store.pool) .await?; Ok(()) } pub async fn get( store: &crate::db::Store, data_dir: &Path, secret_id: &str, expected_provider: Option<&str>, ) -> Result>> { let row = sqlx::query( "SELECT provider, encryption_version, nonce, ciphertext FROM secret_store WHERE secret_id = ?", ) .bind(secret_id) .fetch_optional(&store.pool) .await? .ok_or_else(|| anyhow::anyhow!("secret not found"))?; let provider: String = row.try_get("provider")?; if let Some(expected) = expected_provider { ensure!(provider == expected, "secret provider mismatch"); } validate_identity(secret_id, &provider)?; let version: i64 = row.try_get("encryption_version")?; ensure!(version == ENCRYPTION_VERSION, "unsupported secret encryption version"); let nonce: Vec = row.try_get("nonce")?; ensure!(nonce.len() == NONCE_BYTES, "invalid secret nonce"); let mut ciphertext = Zeroizing::new(row.try_get::, _>("ciphertext")?); ensure!( ciphertext.len() >= AES_256_GCM.tag_len() && ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(), "invalid encrypted secret size" ); let mut nonce_bytes = [0u8; NONCE_BYTES]; nonce_bytes.copy_from_slice(&nonce); let cipher = key(data_dir)?; let plaintext = cipher .open_in_place( Nonce::assume_unique_for_key(nonce_bytes), Aad::from(aad(secret_id, &provider)), ciphertext.as_mut(), ) .map_err(|_| anyhow::anyhow!("secret authentication failed"))?; let output = Zeroizing::new(plaintext.to_vec()); ciphertext.zeroize(); Ok(output) } pub async fn delete(store: &crate::db::Store, secret_id: &str) -> Result { let result = sqlx::query("DELETE FROM secret_store WHERE secret_id = ?") .bind(secret_id) .execute(&store.pool) .await?; Ok(result.rows_affected() > 0) } pub async fn metadata(store: &crate::db::Store) -> Result> { let rows = sqlx::query( "SELECT secret_id, provider, encryption_version, metadata, created_at, updated_at FROM secret_store ORDER BY secret_id", ) .fetch_all(&store.pool) .await?; rows.into_iter() .map(|row| { let raw: String = row.try_get("metadata")?; Ok(SecretMetadata { secret_id: row.try_get("secret_id")?, provider: row.try_get("provider")?, encryption_version: row.try_get("encryption_version")?, metadata: serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null), created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) }) .collect() } pub async fn validate_all(store: &crate::db::Store, data_dir: &Path) -> Result<()> { for item in metadata(store).await? { let value = get(store, data_dir, &item.secret_id, Some(&item.provider)).await?; ensure!(!value.is_empty(), "empty secret store record"); } Ok(()) } pub fn acme_secret_id(certificate_id: &str, provider: &str) -> String { format!("acme/{certificate_id}/{provider}/credential") } pub async fn migrate_acme_credentials( store: &crate::db::Store, data_dir: &Path, cert_dir: &Path, ) -> Result { initialize(data_dir)?; let root = cert_dir.join("acme"); if !root.exists() { return Ok(0); } let root_meta = fs::symlink_metadata(&root)?; ensure!(root_meta.is_dir() && !root_meta.file_type().is_symlink(), "invalid ACME directory"); let mut migrated = 0usize; for entry in fs::read_dir(&root)? { let entry = entry?; let profile_dir = entry.path(); let meta = fs::symlink_metadata(&profile_dir)?; if !meta.is_dir() || meta.file_type().is_symlink() { continue; } let profile_path = profile_dir.join("profile.json"); if !profile_path.is_file() { continue; } let profile: serde_json::Value = serde_json::from_slice(&fs::read(&profile_path)?)?; let provider = profile.get("provider").and_then(serde_json::Value::as_str).unwrap_or(""); let certificate_id = profile .get("certificate_id") .and_then(serde_json::Value::as_str) .unwrap_or(""); if provider == "http01" { continue; } validate_identity(&acme_secret_id(certificate_id, provider), provider)?; let credential_path = profile_dir.join(format!("{provider}.ini")); if !credential_path.exists() { continue; } let credential_meta = fs::symlink_metadata(&credential_path)?; ensure!( credential_meta.is_file() && !credential_meta.file_type().is_symlink() && credential_meta.len() <= MAX_SECRET_BYTES as u64, "unsafe legacy ACME credential file" ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; ensure!( credential_meta.permissions().mode() & 0o077 == 0, "legacy ACME credential file must be private" ); } let mut plaintext = Zeroizing::new(fs::read(&credential_path)?); ensure!(!plaintext.is_empty(), "empty legacy ACME credential file"); put( store, data_dir, &acme_secret_id(certificate_id, provider), provider, plaintext.as_ref(), serde_json::json!({"source":"acme_credential_migration","certificate_id":certificate_id}), ) .await?; plaintext.zeroize(); fs::remove_file(&credential_path).context("legacy ACME credential was encrypted but could not be removed")?; migrated += 1; } Ok(migrated) } pub fn read_master_key_for_backup(data_dir: &Path) -> Result> { initialize(data_dir)?; read_key(&master_key_path(data_dir)) } pub fn write_restored_master_key(data_dir: &Path, key: &[u8]) -> Result<()> { ensure!(key.len() == KEY_BYTES, "invalid restored secret store key"); let directory = secrets_dir(data_dir); private_directory(&directory)?; let path = master_key_path(data_dir); ensure!(!path.exists(), "restored secret store key already exists"); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } let mut file = options.open(&path)?; file.write_all(key)?; file.sync_all()?; Ok(()) } #[cfg(test)] include!("secret_store/tests.rs");