use std::path::{Path, PathBuf};
use secrecy::{ExposeSecret, SecretString};
use crate::core::{config::Config, crypto, git, keys, manifest::Manifest, metadata::SecretMetadata, paths};
use crate::error::VaultError;
#[derive(Debug)]
pub enum CheckIssue {
Warning(String),
Error(String),
}
pub struct Vault {
pub paths: paths::VaultPaths,
}
impl Vault {
pub fn open(root: &Path) -> Result<Self, VaultError> {
let vault = Self {
paths: paths::VaultPaths::new(root),
};
if !vault.paths.vault_dir().exists() {
return Err(VaultError::NotInitialized);
}
Ok(vault)
}
pub fn init(root: &Path) -> Result<Self, VaultError> {
let vault_paths = paths::VaultPaths::new(root);
if vault_paths.vault_dir().exists() {
return Err(VaultError::AlreadyInitialized(
vault_paths.vault_dir().display().to_string(),
));
}
std::fs::create_dir_all(vault_paths.agents_dir())?;
std::fs::create_dir_all(vault_paths.secrets_dir())?;
let (owner_secret, owner_public) = crypto::generate_keypair();
let owner_key_path = paths::owner_key_path();
keys::save_private_key(&owner_key_path, &owner_secret)?;
keys::save_public_key(&vault_paths.owner_pub_file(), &owner_public)?;
let config = Config::new();
config.save(&vault_paths.config_file())?;
let owner_name = whoami();
let manifest = Manifest::new(&owner_name);
manifest.save(&vault_paths.manifest_file())?;
std::fs::write(vault_paths.gitignore_file(), git::gitignore_content())?;
let repo = git::open_repo(root)?;
git::install_pre_commit_hook(&repo)?;
let files_to_commit = vec![
vault_paths.config_file(),
vault_paths.owner_pub_file(),
vault_paths.manifest_file(),
vault_paths.gitignore_file(),
];
git::commit_files(&repo, &files_to_commit, "agent-vault: initialize vault")?;
Ok(Self {
paths: vault_paths,
})
}
pub fn add_agent(&self, name: &str) -> Result<PathBuf, VaultError> {
let agent_dir = self.paths.agent_dir(name);
if agent_dir.exists() {
return Err(VaultError::AgentExists(name.to_string()));
}
let (agent_secret, agent_public) = crypto::generate_keypair();
let agent_key_path = paths::agent_key_path(name);
keys::save_private_key(&agent_key_path, &agent_secret)?;
std::fs::create_dir_all(&agent_dir)?;
keys::save_public_key(&self.paths.agent_pub_file(name), &agent_public)?;
let owner_pub = keys::load_public_key(&self.paths.owner_pub_file())?;
keys::create_escrow(
&agent_secret,
&owner_pub,
&self.paths.agent_escrow_file(name),
)?;
let mut manifest = Manifest::load(&self.paths.manifest_file())?;
manifest.add_agent(name)?;
manifest.save(&self.paths.manifest_file())?;
let repo = git::open_repo(self.paths.root())?;
let files = vec![
self.paths.agent_pub_file(name),
self.paths.agent_escrow_file(name),
self.paths.manifest_file(),
];
git::commit_files(
&repo,
&files,
&format!("agent-vault: add agent '{name}'"),
)?;
Ok(agent_key_path)
}
pub fn set_secret(
&self,
secret_path: &str,
value: &str,
group: &str,
expires: Option<chrono::DateTime<chrono::Utc>>,
extra_agents: Option<&[String]>,
) -> Result<(), VaultError> {
let mut manifest = Manifest::load(&self.paths.manifest_file())?;
manifest.add_secret_to_group(group, secret_path);
let mut all_agents = manifest.agents_in_group(group);
if let Some(extras) = extra_agents {
for agent_name in extras {
if !manifest.agents.iter().any(|a| a.name == *agent_name) {
return Err(VaultError::AgentNotFound(agent_name.clone()));
}
if !all_agents.contains(agent_name) {
all_agents.push(agent_name.clone());
}
}
}
let mut recipients = vec![];
let owner_pub_str = keys::load_public_key(&self.paths.owner_pub_file())?;
let owner_recipient = crypto::parse_recipient(&owner_pub_str)?;
recipients.push(owner_recipient);
for agent_name in &all_agents {
let pub_path = self.paths.agent_pub_file(agent_name);
if pub_path.exists() {
let pub_str = keys::load_public_key(&pub_path)?;
let recipient = crypto::parse_recipient(&pub_str)?;
recipients.push(recipient);
}
}
let ciphertext = crypto::encrypt(value.as_bytes(), &recipients)?;
let enc_path = self.paths.secret_enc_file(secret_path);
if let Some(parent) = enc_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&enc_path, &ciphertext)?;
let meta_path = self.paths.secret_meta_file(secret_path);
let mut meta = if meta_path.exists() {
let mut existing = SecretMetadata::load(&meta_path)?;
existing.rotated = chrono::Utc::now();
existing.authorized_agents = all_agents.clone();
existing
} else {
SecretMetadata::new(secret_path, group, all_agents.clone())
};
if let Some(exp) = expires {
meta.expires = Some(exp);
}
meta.save(&meta_path)?;
manifest.save(&self.paths.manifest_file())?;
let repo = git::open_repo(self.paths.root())?;
let files = vec![enc_path, meta_path, self.paths.manifest_file()];
git::commit_files(
&repo,
&files,
&format!("agent-vault: set secret '{secret_path}'"),
)?;
Ok(())
}
pub fn pull(&self) -> Result<(), VaultError> {
let repo = git::open_repo(self.paths.root())?;
git::pull(&repo)
}
pub fn get_secret(&self, secret_path: &str, key_path: &Path) -> Result<SecretString, VaultError> {
let enc_path = self.paths.secret_enc_file(secret_path);
if !enc_path.exists() {
return Err(VaultError::SecretNotFound(secret_path.to_string()));
}
let private_key = keys::load_private_key(key_path)?;
let identity = crypto::parse_identity(private_key.expose_secret())?;
let ciphertext = std::fs::read(&enc_path)?;
crypto::decrypt(&ciphertext, &identity)
}
pub fn list_agents(&self) -> Result<Vec<(String, Vec<String>)>, VaultError> {
let manifest = Manifest::load(&self.paths.manifest_file())?;
let result = manifest
.agents
.iter()
.map(|a| (a.name.clone(), a.groups.clone()))
.collect();
Ok(result)
}
pub fn list_secrets(&self, group_filter: Option<&str>) -> Result<Vec<SecretMetadata>, VaultError> {
let secrets_dir = self.paths.secrets_dir();
if !secrets_dir.exists() {
return Ok(vec![]);
}
let mut results = vec![];
for group_entry in std::fs::read_dir(&secrets_dir)? {
let group_entry = group_entry?;
if !group_entry.file_type()?.is_dir() {
continue;
}
let group_name = group_entry.file_name().to_string_lossy().to_string();
if let Some(filter) = group_filter {
if group_name != filter {
continue;
}
}
for file_entry in std::fs::read_dir(group_entry.path())? {
let file_entry = file_entry?;
let fname = file_entry.file_name().to_string_lossy().to_string();
if fname.ends_with(".meta") {
let meta = SecretMetadata::load(&file_entry.path())?;
results.push(meta);
}
}
}
Ok(results)
}
fn re_encrypt_secret(&self, secret_path: &str, manifest: &Manifest) -> Result<Vec<PathBuf>, VaultError> {
let owner_key_path = paths::owner_key_path();
let owner_private = keys::load_private_key(&owner_key_path)?;
let owner_identity = crypto::parse_identity(owner_private.expose_secret())?;
let enc_path = self.paths.secret_enc_file(secret_path);
let ciphertext = std::fs::read(&enc_path)?;
let plaintext = crypto::decrypt(&ciphertext, &owner_identity)?;
let mut recipients = vec![];
let owner_pub_str = keys::load_public_key(&self.paths.owner_pub_file())?;
recipients.push(crypto::parse_recipient(&owner_pub_str)?);
let authorized = manifest.authorized_agents_for_secret(secret_path);
for agent_name in &authorized {
let pub_path = self.paths.agent_pub_file(agent_name);
if pub_path.exists() {
let pub_str = keys::load_public_key(&pub_path)?;
recipients.push(crypto::parse_recipient(&pub_str)?);
}
}
let new_ciphertext = crypto::encrypt(plaintext.expose_secret().as_bytes(), &recipients)?;
std::fs::write(&enc_path, &new_ciphertext)?;
let meta_path = self.paths.secret_meta_file(secret_path);
if meta_path.exists() {
let mut meta = SecretMetadata::load(&meta_path)?;
meta.authorized_agents = authorized;
meta.rotated = chrono::Utc::now();
meta.save(&meta_path)?;
}
Ok(vec![enc_path, meta_path])
}
pub fn grant_agent(&self, agent_name: &str, group_name: &str) -> Result<Vec<String>, VaultError> {
let mut manifest = Manifest::load(&self.paths.manifest_file())?;
manifest.grant(agent_name, group_name)?;
let secret_paths = manifest.secrets_in_group(group_name);
let mut changed_files = vec![self.paths.manifest_file()];
for sp in &secret_paths {
let mut files = self.re_encrypt_secret(sp, &manifest)?;
changed_files.append(&mut files);
}
manifest.save(&self.paths.manifest_file())?;
let repo = git::open_repo(self.paths.root())?;
git::commit_files(
&repo,
&changed_files,
&format!("agent-vault: grant '{agent_name}' access to '{group_name}'"),
)?;
Ok(secret_paths)
}
pub fn revoke_agent(&self, agent_name: &str, group_name: &str) -> Result<Vec<String>, VaultError> {
let mut manifest = Manifest::load(&self.paths.manifest_file())?;
manifest.revoke(agent_name, group_name)?;
let secret_paths = manifest.secrets_in_group(group_name);
let mut changed_files = vec![self.paths.manifest_file()];
for sp in &secret_paths {
let mut files = self.re_encrypt_secret(sp, &manifest)?;
changed_files.append(&mut files);
}
manifest.save(&self.paths.manifest_file())?;
let repo = git::open_repo(self.paths.root())?;
git::commit_files(
&repo,
&changed_files,
&format!("agent-vault: revoke '{agent_name}' access to '{group_name}'"),
)?;
Ok(secret_paths)
}
pub fn remove_agent(&self, name: &str) -> Result<Vec<String>, VaultError> {
let mut manifest = Manifest::load(&self.paths.manifest_file())?;
let groups = manifest.remove_agent(name)?;
let mut all_secret_paths = vec![];
for group_name in &groups {
for sp in manifest.secrets_in_group(group_name) {
if !all_secret_paths.contains(&sp) {
all_secret_paths.push(sp);
}
}
}
let mut changed_files = vec![self.paths.manifest_file()];
for sp in &all_secret_paths {
let mut files = self.re_encrypt_secret(sp, &manifest)?;
changed_files.append(&mut files);
}
manifest.save(&self.paths.manifest_file())?;
let repo = git::open_repo(self.paths.root())?;
let agent_relative = std::path::Path::new(".agent-vault").join("agents").join(name);
git::remove_dir_from_index(&repo, &agent_relative)?;
let agent_dir = self.paths.agent_dir(name);
if agent_dir.exists() {
std::fs::remove_dir_all(&agent_dir)?;
}
git::commit_files(
&repo,
&changed_files,
&format!("agent-vault: remove agent '{name}'"),
)?;
Ok(groups)
}
pub fn recover_agent(&self, name: &str) -> Result<PathBuf, VaultError> {
let escrow_path = self.paths.agent_escrow_file(name);
if !escrow_path.exists() {
return Err(VaultError::AgentNotFound(name.to_string()));
}
let (new_secret, new_public) = crypto::generate_keypair();
let new_key_path = paths::agent_key_path(name);
keys::save_private_key(&new_key_path, &new_secret)?;
keys::save_public_key(&self.paths.agent_pub_file(name), &new_public)?;
let owner_pub = keys::load_public_key(&self.paths.owner_pub_file())?;
keys::create_escrow(&new_secret, &owner_pub, &self.paths.agent_escrow_file(name))?;
let manifest = Manifest::load(&self.paths.manifest_file())?;
let agent_groups = manifest
.agent_groups(name)
.unwrap_or_default();
let mut changed_files = vec![
self.paths.agent_pub_file(name),
self.paths.agent_escrow_file(name),
];
for group_name in &agent_groups {
for sp in manifest.secrets_in_group(group_name) {
let mut files = self.re_encrypt_secret(&sp, &manifest)?;
changed_files.append(&mut files);
}
}
let repo = git::open_repo(self.paths.root())?;
git::commit_files(
&repo,
&changed_files,
&format!("agent-vault: recover agent '{name}' with new keypair"),
)?;
Ok(new_key_path)
}
pub fn restore_agent(&self, name: &str, to_path: &Path) -> Result<(), VaultError> {
let escrow_path = self.paths.agent_escrow_file(name);
if !escrow_path.exists() {
return Err(VaultError::AgentNotFound(name.to_string()));
}
let owner_key_path = paths::owner_key_path();
let owner_private = keys::load_private_key(&owner_key_path)?;
let agent_private = keys::recover_from_escrow(&escrow_path, &owner_private)?;
keys::save_private_key(to_path, &SecretString::from(agent_private.expose_secret().to_string()))?;
Ok(())
}
pub fn check(&self) -> Result<Vec<CheckIssue>, VaultError> {
let manifest = Manifest::load(&self.paths.manifest_file())?;
let mut issues = vec![];
if let Err(e) = Config::load(&self.paths.config_file()) {
issues.push(CheckIssue::Error(format!("Invalid config.yaml: {e}")));
}
for agent in &manifest.agents {
if agent.groups.is_empty() {
issues.push(CheckIssue::Warning(format!(
"Agent '{}' has no group access",
agent.name
)));
}
}
for group in &manifest.groups {
if group.secrets.is_empty() {
issues.push(CheckIssue::Warning(format!(
"Group '{}' has no secrets",
group.name
)));
}
if manifest.agents_in_group(&group.name).is_empty() {
issues.push(CheckIssue::Warning(format!(
"Group '{}' has no agents assigned",
group.name
)));
}
}
let secrets_dir = self.paths.secrets_dir();
if secrets_dir.exists() {
for group_entry in std::fs::read_dir(&secrets_dir)? {
let group_entry = group_entry?;
if !group_entry.file_type()?.is_dir() {
continue;
}
let group_name = group_entry.file_name().to_string_lossy().to_string();
for file_entry in std::fs::read_dir(group_entry.path())? {
let file_entry = file_entry?;
let fname = file_entry.file_name().to_string_lossy().to_string();
if let Some(secret_name) = fname.strip_suffix(".enc") {
let secret_path = format!("{group_name}/{secret_name}");
if manifest.authorized_agents_for_secret(&secret_path).is_empty()
&& !manifest.groups.iter().any(|g| g.secrets.contains(&secret_path))
{
issues.push(CheckIssue::Warning(format!(
"Orphaned secret file: {secret_path}"
)));
}
}
}
}
}
for group in &manifest.groups {
for secret_path in &group.secrets {
let enc_path = self.paths.secret_enc_file(secret_path);
if !enc_path.exists() {
issues.push(CheckIssue::Error(format!(
"Secret '{secret_path}' listed in manifest but .enc file missing"
)));
}
}
}
for agent in &manifest.agents {
let pub_path = self.paths.agent_pub_file(&agent.name);
if !pub_path.exists() {
issues.push(CheckIssue::Error(format!(
"Agent '{}' in manifest but public key file missing",
agent.name
)));
}
let escrow_path = self.paths.agent_escrow_file(&agent.name);
if !escrow_path.exists() {
issues.push(CheckIssue::Error(format!(
"Agent '{}' missing escrow file",
agent.name
)));
}
}
let secrets = self.list_secrets(None)?;
let now = chrono::Utc::now();
for meta in &secrets {
if let Some(expires) = meta.expires {
let days_until = (expires - now).num_days();
if days_until < 0 {
issues.push(CheckIssue::Error(format!(
"Secret '{}' expired {} days ago",
meta.name,
-days_until
)));
} else if days_until < 30 {
issues.push(CheckIssue::Warning(format!(
"Secret '{}' expires in {} days",
meta.name, days_until
)));
}
}
}
let owner_key = paths::owner_key_path();
if !owner_key.exists() {
issues.push(CheckIssue::Warning(
"Owner private key not found at ~/.agent-vault/owner.key".to_string(),
));
}
Ok(issues)
}
pub fn resolve_identity_key(key_flag: Option<&str>) -> Result<PathBuf, VaultError> {
if let Some(k) = key_flag {
let p = PathBuf::from(k);
if p.exists() {
return Ok(p);
}
return Err(VaultError::NoIdentityKey);
}
if let Ok(env_key) = std::env::var("AGENT_VAULT_KEY") {
if env_key.contains("AGE-SECRET-KEY-") {
let key_dir = paths::home_vault_dir();
std::fs::create_dir_all(&key_dir)?;
let tmp_key_path = key_dir.join(".env-key.tmp");
std::fs::write(&tmp_key_path, &env_key)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&tmp_key_path,
std::fs::Permissions::from_mode(0o600),
)?;
}
return Ok(tmp_key_path);
}
let p = PathBuf::from(&env_key);
if p.exists() {
return Ok(p);
}
}
let owner_path = paths::owner_key_path();
if owner_path.exists() {
return Ok(owner_path);
}
Err(VaultError::NoIdentityKey)
}
}
fn whoami() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "owner".to_string())
}