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 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 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 std::fs::create_dir_all(vault_paths.agents_dir())?;
42 std::fs::create_dir_all(vault_paths.secrets_dir())?;
43
44 let (owner_secret, owner_public) = crypto::generate_keypair();
46
47 let owner_key_path = paths::owner_key_path();
49 keys::save_private_key(&owner_key_path, &owner_secret)?;
50
51 keys::save_public_key(&vault_paths.owner_pub_file(), &owner_public)?;
53
54 let config = Config::new();
56 config.save(&vault_paths.config_file())?;
57
58 let owner_name = whoami();
60 let manifest = Manifest::new(&owner_name);
61 manifest.save(&vault_paths.manifest_file())?;
62
63 std::fs::write(vault_paths.gitignore_file(), git::gitignore_content())?;
65
66 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 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 let (agent_secret, agent_public) = crypto::generate_keypair();
92
93 let agent_key_path = paths::agent_key_path(name);
95 keys::save_private_key(&agent_key_path, &agent_secret)?;
96
97 std::fs::create_dir_all(&agent_dir)?;
99 keys::save_public_key(&self.paths.agent_pub_file(name), &agent_public)?;
100
101 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 let mut manifest = Manifest::load(&self.paths.manifest_file())?;
111 manifest.add_agent(name)?;
112 manifest.save(&self.paths.manifest_file())?;
113
114 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 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 manifest.add_secret_to_group(group, secret_path);
144
145 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 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 let ciphertext = crypto::encrypt(value.as_bytes(), &recipients)?;
176
177 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 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 manifest.save(&self.paths.manifest_file())?;
201
202 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 pub fn pull(&self) -> Result<(), VaultError> {
216 let repo = git::open_repo(self.paths.root())?;
217 git::pull(&repo)
218 }
219
220 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 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 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 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 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 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 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 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 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 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 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 let agent_dir = self.paths.agent_dir(name);
399 if agent_dir.exists() {
400 std::fs::remove_dir_all(&agent_dir)?;
401 }
402
403 git::commit_files(
405 &repo,
406 &changed_files,
407 &format!("agent-vault: remove agent '{name}'"),
408 )?;
409
410 Ok(groups)
411 }
412
413 pub fn recover_agent(&self, name: &str) -> Result<PathBuf, VaultError> {
416 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 let (new_secret, new_public) = crypto::generate_keypair();
424
425 let new_key_path = paths::agent_key_path(name);
427 keys::save_private_key(&new_key_path, &new_secret)?;
428
429 keys::save_public_key(&self.paths.agent_pub_file(name), &new_public)?;
431
432 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 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 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 pub fn check(&self) -> Result<Vec<CheckIssue>, VaultError> {
483 let manifest = Manifest::load(&self.paths.manifest_file())?;
484 let mut issues = vec![];
485
486 if let Err(e) = Config::load(&self.paths.config_file()) {
488 issues.push(CheckIssue::Error(format!("Invalid config.yaml: {e}")));
489 }
490
491 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 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 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 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 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 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 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 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 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}