Skip to main content

agent_vault/core/
vault.rs

1use std::path::{Path, PathBuf};
2
3use secrecy::{ExposeSecret, SecretString};
4
5use crate::core::{config::Config, crypto, git, keys, manifest::Manifest, metadata::SecretMetadata, paths};
6use crate::error::VaultError;
7
8#[derive(Debug)]
9pub enum CheckIssue {
10    Warning(String),
11    Error(String),
12}
13
14pub struct Vault {
15    pub paths: paths::VaultPaths,
16}
17
18impl Vault {
19    /// Open a vault rooted at the given directory.
20    pub fn open(root: &Path) -> Result<Self, VaultError> {
21        let vault = Self {
22            paths: paths::VaultPaths::new(root),
23        };
24        if !vault.paths.vault_dir().exists() {
25            return Err(VaultError::NotInitialized);
26        }
27        Ok(vault)
28    }
29
30    /// Initialize a new vault in the given directory.
31    pub fn init(root: &Path) -> Result<Self, VaultError> {
32        let vault_paths = paths::VaultPaths::new(root);
33
34        if vault_paths.vault_dir().exists() {
35            return Err(VaultError::AlreadyInitialized(
36                vault_paths.vault_dir().display().to_string(),
37            ));
38        }
39
40        // Create directory structure
41        std::fs::create_dir_all(vault_paths.agents_dir())?;
42        std::fs::create_dir_all(vault_paths.secrets_dir())?;
43
44        // Generate owner keypair
45        let (owner_secret, owner_public) = crypto::generate_keypair();
46
47        // Save owner private key to ~/.agent-vault/owner.key
48        let owner_key_path = paths::owner_key_path();
49        keys::save_private_key(&owner_key_path, &owner_secret)?;
50
51        // Save owner public key to .agent-vault/owner.pub
52        keys::save_public_key(&vault_paths.owner_pub_file(), &owner_public)?;
53
54        // Write config
55        let config = Config::new();
56        config.save(&vault_paths.config_file())?;
57
58        // Write initial manifest
59        let owner_name = whoami();
60        let manifest = Manifest::new(&owner_name);
61        manifest.save(&vault_paths.manifest_file())?;
62
63        // Write .gitignore
64        std::fs::write(vault_paths.gitignore_file(), git::gitignore_content())?;
65
66        // Git operations
67        let repo = git::open_repo(root)?;
68        git::install_pre_commit_hook(&repo)?;
69
70        let files_to_commit = vec![
71            vault_paths.config_file(),
72            vault_paths.owner_pub_file(),
73            vault_paths.manifest_file(),
74            vault_paths.gitignore_file(),
75        ];
76        git::commit_files(&repo, &files_to_commit, "agent-vault: initialize vault")?;
77
78        Ok(Self {
79            paths: vault_paths,
80        })
81    }
82
83    /// Add a new agent to the vault.
84    pub fn add_agent(&self, name: &str) -> Result<PathBuf, VaultError> {
85        let agent_dir = self.paths.agent_dir(name);
86        if agent_dir.exists() {
87            return Err(VaultError::AgentExists(name.to_string()));
88        }
89
90        // Generate agent keypair
91        let (agent_secret, agent_public) = crypto::generate_keypair();
92
93        // Save agent private key locally
94        let agent_key_path = paths::agent_key_path(name);
95        keys::save_private_key(&agent_key_path, &agent_secret)?;
96
97        // Save agent public key to repo
98        std::fs::create_dir_all(&agent_dir)?;
99        keys::save_public_key(&self.paths.agent_pub_file(name), &agent_public)?;
100
101        // Create escrow
102        let owner_pub = keys::load_public_key(&self.paths.owner_pub_file())?;
103        keys::create_escrow(
104            &agent_secret,
105            &owner_pub,
106            &self.paths.agent_escrow_file(name),
107        )?;
108
109        // Update manifest
110        let mut manifest = Manifest::load(&self.paths.manifest_file())?;
111        manifest.add_agent(name)?;
112        manifest.save(&self.paths.manifest_file())?;
113
114        // Commit
115        let repo = git::open_repo(self.paths.root())?;
116        let files = vec![
117            self.paths.agent_pub_file(name),
118            self.paths.agent_escrow_file(name),
119            self.paths.manifest_file(),
120        ];
121        git::commit_files(
122            &repo,
123            &files,
124            &format!("agent-vault: add agent '{name}'"),
125        )?;
126
127        Ok(agent_key_path)
128    }
129
130    /// Set (create or update) a secret.
131    /// `extra_agents` allows encrypting for specific agents beyond the group members.
132    pub fn set_secret(
133        &self,
134        secret_path: &str,
135        value: &str,
136        group: &str,
137        expires: Option<chrono::DateTime<chrono::Utc>>,
138        extra_agents: Option<&[String]>,
139    ) -> Result<(), VaultError> {
140        let mut manifest = Manifest::load(&self.paths.manifest_file())?;
141
142        // Ensure group exists and secret is registered
143        manifest.add_secret_to_group(group, secret_path);
144
145        // Collect authorized agents from group + extras
146        let mut all_agents = manifest.agents_in_group(group);
147        if let Some(extras) = extra_agents {
148            for agent_name in extras {
149                if !manifest.agents.iter().any(|a| a.name == *agent_name) {
150                    return Err(VaultError::AgentNotFound(agent_name.clone()));
151                }
152                if !all_agents.contains(agent_name) {
153                    all_agents.push(agent_name.clone());
154                }
155            }
156        }
157
158        // Collect recipients: owner + all agents
159        let mut recipients = vec![];
160
161        let owner_pub_str = keys::load_public_key(&self.paths.owner_pub_file())?;
162        let owner_recipient = crypto::parse_recipient(&owner_pub_str)?;
163        recipients.push(owner_recipient);
164
165        for agent_name in &all_agents {
166            let pub_path = self.paths.agent_pub_file(agent_name);
167            if pub_path.exists() {
168                let pub_str = keys::load_public_key(&pub_path)?;
169                let recipient = crypto::parse_recipient(&pub_str)?;
170                recipients.push(recipient);
171            }
172        }
173
174        // Encrypt
175        let ciphertext = crypto::encrypt(value.as_bytes(), &recipients)?;
176
177        // Write .enc file
178        let enc_path = self.paths.secret_enc_file(secret_path);
179        if let Some(parent) = enc_path.parent() {
180            std::fs::create_dir_all(parent)?;
181        }
182        std::fs::write(&enc_path, &ciphertext)?;
183
184        // Write .meta file (preserve created timestamp on update)
185        let meta_path = self.paths.secret_meta_file(secret_path);
186        let mut meta = if meta_path.exists() {
187            let mut existing = SecretMetadata::load(&meta_path)?;
188            existing.rotated = chrono::Utc::now();
189            existing.authorized_agents = all_agents.clone();
190            existing
191        } else {
192            SecretMetadata::new(secret_path, group, all_agents.clone())
193        };
194        if let Some(exp) = expires {
195            meta.expires = Some(exp);
196        }
197        meta.save(&meta_path)?;
198
199        // Save updated manifest
200        manifest.save(&self.paths.manifest_file())?;
201
202        // Commit
203        let repo = git::open_repo(self.paths.root())?;
204        let files = vec![enc_path, meta_path, self.paths.manifest_file()];
205        git::commit_files(
206            &repo,
207            &files,
208            &format!("agent-vault: set secret '{secret_path}'"),
209        )?;
210
211        Ok(())
212    }
213
214    /// Pull latest from git (best-effort, silently skips if no remote).
215    pub fn pull(&self) -> Result<(), VaultError> {
216        let repo = git::open_repo(self.paths.root())?;
217        git::pull(&repo)
218    }
219
220    /// Get (decrypt) a secret using the provided identity key.
221    pub fn get_secret(&self, secret_path: &str, key_path: &Path) -> Result<SecretString, VaultError> {
222        let enc_path = self.paths.secret_enc_file(secret_path);
223        if !enc_path.exists() {
224            return Err(VaultError::SecretNotFound(secret_path.to_string()));
225        }
226
227        let private_key = keys::load_private_key(key_path)?;
228        let identity = crypto::parse_identity(private_key.expose_secret())?;
229
230        let ciphertext = std::fs::read(&enc_path)?;
231        crypto::decrypt(&ciphertext, &identity)
232    }
233
234    /// List all agents in the vault.
235    pub fn list_agents(&self) -> Result<Vec<(String, Vec<String>)>, VaultError> {
236        let manifest = Manifest::load(&self.paths.manifest_file())?;
237        let result = manifest
238            .agents
239            .iter()
240            .map(|a| (a.name.clone(), a.groups.clone()))
241            .collect();
242        Ok(result)
243    }
244
245    /// List all secrets, optionally filtered by group.
246    pub fn list_secrets(&self, group_filter: Option<&str>) -> Result<Vec<SecretMetadata>, VaultError> {
247        let secrets_dir = self.paths.secrets_dir();
248        if !secrets_dir.exists() {
249            return Ok(vec![]);
250        }
251
252        let mut results = vec![];
253        for group_entry in std::fs::read_dir(&secrets_dir)? {
254            let group_entry = group_entry?;
255            if !group_entry.file_type()?.is_dir() {
256                continue;
257            }
258            let group_name = group_entry.file_name().to_string_lossy().to_string();
259            if let Some(filter) = group_filter {
260                if group_name != filter {
261                    continue;
262                }
263            }
264            for file_entry in std::fs::read_dir(group_entry.path())? {
265                let file_entry = file_entry?;
266                let fname = file_entry.file_name().to_string_lossy().to_string();
267                if fname.ends_with(".meta") {
268                    let meta = SecretMetadata::load(&file_entry.path())?;
269                    results.push(meta);
270                }
271            }
272        }
273        Ok(results)
274    }
275
276    /// Re-encrypt a single secret for its current set of authorized recipients.
277    /// Decrypts with the owner key, then re-encrypts for owner + all currently authorized agents.
278    fn re_encrypt_secret(&self, secret_path: &str, manifest: &Manifest) -> Result<Vec<PathBuf>, VaultError> {
279        let owner_key_path = paths::owner_key_path();
280        let owner_private = keys::load_private_key(&owner_key_path)?;
281        let owner_identity = crypto::parse_identity(owner_private.expose_secret())?;
282
283        let enc_path = self.paths.secret_enc_file(secret_path);
284        let ciphertext = std::fs::read(&enc_path)?;
285        let plaintext = crypto::decrypt(&ciphertext, &owner_identity)?;
286
287        // Build new recipient list: owner + authorized agents
288        let mut recipients = vec![];
289        let owner_pub_str = keys::load_public_key(&self.paths.owner_pub_file())?;
290        recipients.push(crypto::parse_recipient(&owner_pub_str)?);
291
292        let authorized = manifest.authorized_agents_for_secret(secret_path);
293        for agent_name in &authorized {
294            let pub_path = self.paths.agent_pub_file(agent_name);
295            if pub_path.exists() {
296                let pub_str = keys::load_public_key(&pub_path)?;
297                recipients.push(crypto::parse_recipient(&pub_str)?);
298            }
299        }
300
301        let new_ciphertext = crypto::encrypt(plaintext.expose_secret().as_bytes(), &recipients)?;
302        std::fs::write(&enc_path, &new_ciphertext)?;
303
304        // Update metadata
305        let meta_path = self.paths.secret_meta_file(secret_path);
306        if meta_path.exists() {
307            let mut meta = SecretMetadata::load(&meta_path)?;
308            meta.authorized_agents = authorized;
309            meta.rotated = chrono::Utc::now();
310            meta.save(&meta_path)?;
311        }
312
313        Ok(vec![enc_path, meta_path])
314    }
315
316    /// Grant an agent access to a group. Re-encrypts all secrets in that group.
317    pub fn grant_agent(&self, agent_name: &str, group_name: &str) -> Result<Vec<String>, VaultError> {
318        let mut manifest = Manifest::load(&self.paths.manifest_file())?;
319        manifest.grant(agent_name, group_name)?;
320
321        let secret_paths = manifest.secrets_in_group(group_name);
322        let mut changed_files = vec![self.paths.manifest_file()];
323
324        for sp in &secret_paths {
325            let mut files = self.re_encrypt_secret(sp, &manifest)?;
326            changed_files.append(&mut files);
327        }
328
329        manifest.save(&self.paths.manifest_file())?;
330
331        let repo = git::open_repo(self.paths.root())?;
332        git::commit_files(
333            &repo,
334            &changed_files,
335            &format!("agent-vault: grant '{agent_name}' access to '{group_name}'"),
336        )?;
337
338        Ok(secret_paths)
339    }
340
341    /// Revoke an agent's access to a group. Re-encrypts all secrets in that group.
342    /// Returns the list of secret paths that were re-encrypted.
343    pub fn revoke_agent(&self, agent_name: &str, group_name: &str) -> Result<Vec<String>, VaultError> {
344        let mut manifest = Manifest::load(&self.paths.manifest_file())?;
345        manifest.revoke(agent_name, group_name)?;
346
347        let secret_paths = manifest.secrets_in_group(group_name);
348        let mut changed_files = vec![self.paths.manifest_file()];
349
350        for sp in &secret_paths {
351            let mut files = self.re_encrypt_secret(sp, &manifest)?;
352            changed_files.append(&mut files);
353        }
354
355        manifest.save(&self.paths.manifest_file())?;
356
357        let repo = git::open_repo(self.paths.root())?;
358        git::commit_files(
359            &repo,
360            &changed_files,
361            &format!("agent-vault: revoke '{agent_name}' access to '{group_name}'"),
362        )?;
363
364        Ok(secret_paths)
365    }
366
367    /// Remove an agent from the vault entirely.
368    /// Re-encrypts all secrets the agent had access to, removes agent files.
369    /// Returns the list of groups the agent belonged to (for rotation warnings).
370    pub fn remove_agent(&self, name: &str) -> Result<Vec<String>, VaultError> {
371        let mut manifest = Manifest::load(&self.paths.manifest_file())?;
372        let groups = manifest.remove_agent(name)?;
373
374        // Collect all secrets that need re-encryption
375        let mut all_secret_paths = vec![];
376        for group_name in &groups {
377            for sp in manifest.secrets_in_group(group_name) {
378                if !all_secret_paths.contains(&sp) {
379                    all_secret_paths.push(sp);
380                }
381            }
382        }
383
384        let mut changed_files = vec![self.paths.manifest_file()];
385        for sp in &all_secret_paths {
386            let mut files = self.re_encrypt_secret(sp, &manifest)?;
387            changed_files.append(&mut files);
388        }
389
390        manifest.save(&self.paths.manifest_file())?;
391
392        // Git: remove agent files from index before deleting from disk
393        let repo = git::open_repo(self.paths.root())?;
394        let agent_relative = std::path::Path::new(".agent-vault").join("agents").join(name);
395        git::remove_dir_from_index(&repo, &agent_relative)?;
396
397        // Remove agent directory from disk
398        let agent_dir = self.paths.agent_dir(name);
399        if agent_dir.exists() {
400            std::fs::remove_dir_all(&agent_dir)?;
401        }
402
403        // Commit the manifest + re-encrypted secrets + index removals
404        git::commit_files(
405            &repo,
406            &changed_files,
407            &format!("agent-vault: remove agent '{name}'"),
408        )?;
409
410        Ok(groups)
411    }
412
413    /// Recover an agent: decrypt escrow, generate new keypair, re-encrypt secrets, new escrow.
414    /// Returns the path to the new private key.
415    pub fn recover_agent(&self, name: &str) -> Result<PathBuf, VaultError> {
416        // Verify agent exists
417        let escrow_path = self.paths.agent_escrow_file(name);
418        if !escrow_path.exists() {
419            return Err(VaultError::AgentNotFound(name.to_string()));
420        }
421
422        // Generate new keypair
423        let (new_secret, new_public) = crypto::generate_keypair();
424
425        // Save new private key locally
426        let new_key_path = paths::agent_key_path(name);
427        keys::save_private_key(&new_key_path, &new_secret)?;
428
429        // Update public key in repo
430        keys::save_public_key(&self.paths.agent_pub_file(name), &new_public)?;
431
432        // Create new escrow
433        let owner_pub = keys::load_public_key(&self.paths.owner_pub_file())?;
434        keys::create_escrow(&new_secret, &owner_pub, &self.paths.agent_escrow_file(name))?;
435
436        // Re-encrypt all secrets this agent has access to
437        let manifest = Manifest::load(&self.paths.manifest_file())?;
438        let agent_groups = manifest
439            .agent_groups(name)
440            .unwrap_or_default();
441
442        let mut changed_files = vec![
443            self.paths.agent_pub_file(name),
444            self.paths.agent_escrow_file(name),
445        ];
446
447        for group_name in &agent_groups {
448            for sp in manifest.secrets_in_group(group_name) {
449                let mut files = self.re_encrypt_secret(&sp, &manifest)?;
450                changed_files.append(&mut files);
451            }
452        }
453
454        let repo = git::open_repo(self.paths.root())?;
455        git::commit_files(
456            &repo,
457            &changed_files,
458            &format!("agent-vault: recover agent '{name}' with new keypair"),
459        )?;
460
461        Ok(new_key_path)
462    }
463
464    /// Restore an agent's original private key from escrow.
465    /// Writes the decrypted key to the specified path.
466    pub fn restore_agent(&self, name: &str, to_path: &Path) -> Result<(), VaultError> {
467        let escrow_path = self.paths.agent_escrow_file(name);
468        if !escrow_path.exists() {
469            return Err(VaultError::AgentNotFound(name.to_string()));
470        }
471
472        let owner_key_path = paths::owner_key_path();
473        let owner_private = keys::load_private_key(&owner_key_path)?;
474        let agent_private = keys::recover_from_escrow(&escrow_path, &owner_private)?;
475
476        keys::save_private_key(to_path, &SecretString::from(agent_private.expose_secret().to_string()))?;
477
478        Ok(())
479    }
480
481    /// Audit the vault for issues.
482    pub fn check(&self) -> Result<Vec<CheckIssue>, VaultError> {
483        let manifest = Manifest::load(&self.paths.manifest_file())?;
484        let mut issues = vec![];
485
486        // Verify config is valid
487        if let Err(e) = Config::load(&self.paths.config_file()) {
488            issues.push(CheckIssue::Error(format!("Invalid config.yaml: {e}")));
489        }
490
491        // Check for agents with no group access
492        for agent in &manifest.agents {
493            if agent.groups.is_empty() {
494                issues.push(CheckIssue::Warning(format!(
495                    "Agent '{}' has no group access",
496                    agent.name
497                )));
498            }
499        }
500
501        // Check for empty groups
502        for group in &manifest.groups {
503            if group.secrets.is_empty() {
504                issues.push(CheckIssue::Warning(format!(
505                    "Group '{}' has no secrets",
506                    group.name
507                )));
508            }
509            if manifest.agents_in_group(&group.name).is_empty() {
510                issues.push(CheckIssue::Warning(format!(
511                    "Group '{}' has no agents assigned",
512                    group.name
513                )));
514            }
515        }
516
517        // Check for orphaned secret files (on disk but not in manifest)
518        let secrets_dir = self.paths.secrets_dir();
519        if secrets_dir.exists() {
520            for group_entry in std::fs::read_dir(&secrets_dir)? {
521                let group_entry = group_entry?;
522                if !group_entry.file_type()?.is_dir() {
523                    continue;
524                }
525                let group_name = group_entry.file_name().to_string_lossy().to_string();
526                for file_entry in std::fs::read_dir(group_entry.path())? {
527                    let file_entry = file_entry?;
528                    let fname = file_entry.file_name().to_string_lossy().to_string();
529                    if let Some(secret_name) = fname.strip_suffix(".enc") {
530                        let secret_path = format!("{group_name}/{secret_name}");
531                        if manifest.authorized_agents_for_secret(&secret_path).is_empty()
532                            && !manifest.groups.iter().any(|g| g.secrets.contains(&secret_path))
533                        {
534                            issues.push(CheckIssue::Warning(format!(
535                                "Orphaned secret file: {secret_path}"
536                            )));
537                        }
538                    }
539                }
540            }
541        }
542
543        // Check for missing .enc files referenced in manifest
544        for group in &manifest.groups {
545            for secret_path in &group.secrets {
546                let enc_path = self.paths.secret_enc_file(secret_path);
547                if !enc_path.exists() {
548                    issues.push(CheckIssue::Error(format!(
549                        "Secret '{secret_path}' listed in manifest but .enc file missing"
550                    )));
551                }
552            }
553        }
554
555        // Check for missing agent key files referenced in manifest
556        for agent in &manifest.agents {
557            let pub_path = self.paths.agent_pub_file(&agent.name);
558            if !pub_path.exists() {
559                issues.push(CheckIssue::Error(format!(
560                    "Agent '{}' in manifest but public key file missing",
561                    agent.name
562                )));
563            }
564            let escrow_path = self.paths.agent_escrow_file(&agent.name);
565            if !escrow_path.exists() {
566                issues.push(CheckIssue::Error(format!(
567                    "Agent '{}' missing escrow file",
568                    agent.name
569                )));
570            }
571        }
572
573        // Check for expiring credentials
574        let secrets = self.list_secrets(None)?;
575        let now = chrono::Utc::now();
576        for meta in &secrets {
577            if let Some(expires) = meta.expires {
578                let days_until = (expires - now).num_days();
579                if days_until < 0 {
580                    issues.push(CheckIssue::Error(format!(
581                        "Secret '{}' expired {} days ago",
582                        meta.name,
583                        -days_until
584                    )));
585                } else if days_until < 30 {
586                    issues.push(CheckIssue::Warning(format!(
587                        "Secret '{}' expires in {} days",
588                        meta.name, days_until
589                    )));
590                }
591            }
592        }
593
594        // Check owner key exists
595        let owner_key = paths::owner_key_path();
596        if !owner_key.exists() {
597            issues.push(CheckIssue::Warning(
598                "Owner private key not found at ~/.agent-vault/owner.key".to_string(),
599            ));
600        }
601
602        Ok(issues)
603    }
604
605    /// Resolve the identity key to use for decryption.
606    /// Priority: --key flag > AGENT_VAULT_KEY env > ~/.agent-vault/owner.key
607    ///
608    /// AGENT_VAULT_KEY supports both file paths and raw key strings
609    /// (starting with `AGE-SECRET-KEY-`).
610    pub fn resolve_identity_key(key_flag: Option<&str>) -> Result<PathBuf, VaultError> {
611        if let Some(k) = key_flag {
612            let p = PathBuf::from(k);
613            if p.exists() {
614                return Ok(p);
615            }
616            return Err(VaultError::NoIdentityKey);
617        }
618
619        if let Ok(env_key) = std::env::var("AGENT_VAULT_KEY") {
620            if env_key.contains("AGE-SECRET-KEY-") {
621                // Raw key string — write to a temp file in the home vault dir
622                let key_dir = paths::home_vault_dir();
623                std::fs::create_dir_all(&key_dir)?;
624                let tmp_key_path = key_dir.join(".env-key.tmp");
625                std::fs::write(&tmp_key_path, &env_key)?;
626                #[cfg(unix)]
627                {
628                    use std::os::unix::fs::PermissionsExt;
629                    std::fs::set_permissions(
630                        &tmp_key_path,
631                        std::fs::Permissions::from_mode(0o600),
632                    )?;
633                }
634                return Ok(tmp_key_path);
635            }
636
637            let p = PathBuf::from(&env_key);
638            if p.exists() {
639                return Ok(p);
640            }
641        }
642
643        let owner_path = paths::owner_key_path();
644        if owner_path.exists() {
645            return Ok(owner_path);
646        }
647
648        Err(VaultError::NoIdentityKey)
649    }
650}
651
652fn whoami() -> String {
653    std::env::var("USER")
654        .or_else(|_| std::env::var("USERNAME"))
655        .unwrap_or_else(|_| "owner".to_string())
656}