use std::collections::BTreeMap;
use std::fmt;
use std::future::Future;
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use zeroize::Zeroizing;
use crate::VaultProvider;
use zeph_common::secret::VaultError;
#[derive(Debug, thiserror::Error)]
pub enum AgeVaultError {
#[error("failed to read key file: {0}")]
KeyRead(std::io::Error),
#[error("failed to parse age identity: {0}")]
KeyParse(String),
#[error("failed to read vault file: {0}")]
VaultRead(std::io::Error),
#[error("age decryption failed: {0}")]
Decrypt(age::DecryptError),
#[error("I/O error during decryption: {0}")]
Io(std::io::Error),
#[error("invalid JSON in vault: {0}")]
Json(serde_json::Error),
#[error("age encryption failed: {0}")]
Encrypt(String),
#[error("failed to write vault file: {0}")]
VaultWrite(std::io::Error),
#[error("failed to write key file: {0}")]
KeyWrite(std::io::Error),
}
pub struct AgeVaultProvider {
pub(crate) secrets: BTreeMap<String, Zeroizing<String>>,
pub(crate) key_path: PathBuf,
pub(crate) vault_path: PathBuf,
}
impl fmt::Debug for AgeVaultProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgeVaultProvider")
.field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
.field("key_path", &self.key_path)
.field("vault_path", &self.vault_path)
.finish()
}
}
impl AgeVaultProvider {
pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
Self::load(key_path, vault_path)
}
pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
let key_str =
Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
let identity = parse_identity(&key_str)?;
let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
let secrets = decrypt_secrets(&identity, &ciphertext)?;
Ok(Self {
secrets,
key_path: key_path.to_owned(),
vault_path: vault_path.to_owned(),
})
}
pub fn save(&self) -> Result<(), AgeVaultError> {
let key_str = Zeroizing::new(
std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
);
let identity = parse_identity(&key_str)?;
let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
atomic_write(&self.vault_path, &ciphertext)
}
pub fn set_secret_mut(&mut self, key: String, value: String) {
self.secrets.insert(key, Zeroizing::new(value));
}
pub fn remove_secret_mut(&mut self, key: &str) -> bool {
self.secrets.remove(key).is_some()
}
#[must_use]
pub fn list_keys(&self) -> Vec<&str> {
let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
keys.sort_unstable();
keys
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.secrets.get(key).map(|v| v.as_str())
}
pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
use age::secrecy::ExposeSecret as _;
std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
let identity = age::x25519::Identity::generate();
let public_key = identity.to_public();
let key_content = Zeroizing::new(format!(
"# public key: {}\n{}\n",
public_key,
identity.to_string().expose_secret()
));
let key_path = dir.join("vault-key.txt");
write_private_file(&key_path, key_content.as_bytes())?;
let vault_path = dir.join("secrets.age");
let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
let ciphertext = encrypt_secrets(&identity, &empty)?;
atomic_write(&vault_path, &ciphertext)?;
println!("Vault initialized:");
println!(" Key: {}", key_path.display());
println!(" Vault: {}", vault_path.display());
Ok(())
}
}
impl VaultProvider for AgeVaultProvider {
fn get_secret(
&self,
key: &str,
) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
let result = self.secrets.get(key).map(|v| (**v).clone());
Box::pin(async move { Ok(result) })
}
fn list_keys(&self) -> Vec<String> {
let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
keys.sort_unstable();
keys
}
}
pub(crate) fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
let key_line = key_str
.lines()
.find(|l| !l.starts_with('#') && !l.trim().is_empty())
.ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
key_line
.trim()
.parse()
.map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
}
pub(crate) fn decrypt_secrets(
identity: &age::x25519::Identity,
ciphertext: &[u8],
) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
let mut reader = decryptor
.decrypt(std::iter::once(identity as &dyn age::Identity))
.map_err(AgeVaultError::Decrypt)?;
let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
reader
.read_to_end(&mut plaintext)
.map_err(AgeVaultError::Io)?;
let raw: BTreeMap<String, String> =
serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
Ok(raw
.into_iter()
.map(|(k, v)| (k, Zeroizing::new(v)))
.collect())
}
pub(crate) fn encrypt_secrets(
identity: &age::x25519::Identity,
secrets: &BTreeMap<String, Zeroizing<String>>,
) -> Result<Vec<u8>, AgeVaultError> {
let recipient = identity.to_public();
let encryptor =
age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
.map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
let plain: BTreeMap<&str, &str> = secrets
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
let mut ciphertext = Vec::with_capacity(json.len() + 64);
let mut writer = encryptor
.wrap_output(&mut ciphertext)
.map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
writer.write_all(&json).map_err(AgeVaultError::Io)?;
writer
.finish()
.map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
Ok(ciphertext)
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
}
pub(crate) fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
}