1use crate::identity::{AgentIdentity, IdentityError};
31use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
32use ring::pbkdf2;
33use ring::rand::{SecureRandom, SystemRandom};
34use serde::{Deserialize, Serialize};
35use std::fs;
36use std::io::{Read, Write};
37use std::num::NonZeroU32;
38use std::path::{Path, PathBuf};
39use thiserror::Error;
40use zeroize::Zeroize;
41
42const VAULT_MAGIC: [u8; 4] = [0xA9, 0x1D, 0x56, 0x01];
43const VAULT_VERSION: u8 = 0x01;
44const SALT_LEN: usize = 16;
45const NONCE_LEN: usize = 12;
46const KEY_LEN: usize = 32;
47const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN + 4;
48const DEFAULT_PBKDF2_ITERS: u32 = 200_000;
49const MIN_PBKDF2_ITERS: u32 = 50_000;
50
51#[derive(Error, Debug)]
52pub enum VaultError {
53 #[error("io: {0}")]
54 Io(#[from] std::io::Error),
55 #[error("invalid vault file magic")]
56 InvalidMagic,
57 #[error("unsupported vault file version: {0:#x}")]
58 UnsupportedVersion(u8),
59 #[error("pbkdf2 iterations too low: {got} (min {min})", min = MIN_PBKDF2_ITERS)]
60 IterationsTooLow { got: u32 },
61 #[error("malformed vault file: {0}")]
62 Malformed(&'static str),
63 #[error("decryption failed (wrong password?)")]
64 DecryptionFailed,
65 #[error(transparent)]
66 Identity(#[from] IdentityError),
67 #[error("serde: {0}")]
68 Serde(#[from] serde_json::Error),
69 #[error("vault not initialized at {0}")]
70 NotInitialized(PathBuf),
71 #[error("identity already exists: {0}")]
72 AlreadyExists(String),
73 #[error("identity not found: {0}")]
74 NotFound(String),
75 #[error("home directory not found")]
76 NoHome,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct VaultEntry {
82 pub name: String,
83 pub project: String,
84 pub fingerprint: String,
85 pub public_key: String, pub created_at: i64,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct VaultIndex {
91 pub version: u32,
92 #[serde(default)]
93 pub entries: Vec<VaultEntry>,
94}
95
96impl Default for VaultIndex {
97 fn default() -> Self {
98 Self {
99 version: 1,
100 entries: Vec::new(),
101 }
102 }
103}
104
105#[derive(Serialize, Deserialize, Zeroize)]
107#[zeroize(drop)]
108struct StoredKey {
109 name: String,
110 project: String,
111 secret_hex: String,
113 created_at: i64,
114}
115
116pub struct Vault {
118 root: PathBuf,
119}
120
121impl Vault {
122 pub fn default_root() -> Result<PathBuf, VaultError> {
124 Ok(dirs::home_dir().ok_or(VaultError::NoHome)?.join(".agentid"))
125 }
126
127 pub fn new(root: impl Into<PathBuf>) -> Self {
129 Self { root: root.into() }
130 }
131
132 pub fn root(&self) -> &Path {
133 &self.root
134 }
135
136 pub fn keys_dir(&self) -> PathBuf {
137 self.root.join("keys")
138 }
139
140 pub fn index_path(&self) -> PathBuf {
141 self.root.join("index.json")
142 }
143
144 pub fn init(&self) -> Result<(), VaultError> {
146 fs::create_dir_all(self.keys_dir())?;
147 if !self.index_path().exists() {
148 self.write_index(&VaultIndex::default())?;
149 }
150 set_dir_perms(&self.root)?;
151 set_dir_perms(&self.keys_dir())?;
152 Ok(())
153 }
154
155 pub fn is_initialized(&self) -> bool {
156 self.index_path().exists()
157 }
158
159 pub fn read_index(&self) -> Result<VaultIndex, VaultError> {
162 if !self.index_path().exists() {
163 return Err(VaultError::NotInitialized(self.root.clone()));
164 }
165 let bytes = fs::read(self.index_path())?;
166 Ok(serde_json::from_slice(&bytes)?)
167 }
168
169 fn write_index(&self, idx: &VaultIndex) -> Result<(), VaultError> {
170 let s = serde_json::to_string_pretty(idx)?;
171 fs::write(self.index_path(), s)?;
172 set_file_perms(&self.index_path())?;
173 Ok(())
174 }
175
176 pub fn list(&self) -> Result<Vec<VaultEntry>, VaultError> {
178 Ok(self.read_index()?.entries)
179 }
180
181 pub fn store(&self, identity: &AgentIdentity, password: &str) -> Result<VaultEntry, VaultError> {
183 let mut idx = self.read_index()?;
184 let fingerprint = identity.fingerprint();
185 if idx.entries.iter().any(|e| e.fingerprint == fingerprint) {
186 return Err(VaultError::AlreadyExists(fingerprint));
187 }
188 let entry = VaultEntry {
189 name: identity.name.clone(),
190 project: identity.project.clone(),
191 fingerprint: fingerprint.clone(),
192 public_key: identity.public_key_hex(),
193 created_at: now_secs(),
194 };
195 let mut secret = identity.secret_bytes();
196 let stored = StoredKey {
197 name: identity.name.clone(),
198 project: identity.project.clone(),
199 secret_hex: hex::encode(secret),
200 created_at: entry.created_at,
201 };
202 secret.zeroize();
203 let plaintext = serde_json::to_vec(&stored)?;
204 let key_path = self.key_file_path(&fingerprint);
205 encrypt_to_file(&key_path, &plaintext, password)?;
206 drop(plaintext);
208
209 idx.entries.push(entry.clone());
210 self.write_index(&idx)?;
211 Ok(entry)
212 }
213
214 pub fn load(&self, fingerprint: &str, password: &str) -> Result<AgentIdentity, VaultError> {
216 let key_path = self.key_file_path(fingerprint);
217 if !key_path.exists() {
218 return Err(VaultError::NotFound(fingerprint.to_string()));
219 }
220 let mut plaintext = decrypt_from_file(&key_path, password)?;
221 let stored: StoredKey = serde_json::from_slice(&plaintext)?;
222 plaintext.zeroize();
223 let mut secret = hex::decode(&stored.secret_hex)
224 .map_err(|_| VaultError::Malformed("invalid secret_hex"))?;
225 let identity = AgentIdentity::from_secret_bytes(&stored.name, &stored.project, &secret)?;
226 secret.zeroize();
227 Ok(identity)
228 }
229
230 pub fn lookup_by_name_project(
232 &self,
233 name: &str,
234 project: &str,
235 ) -> Result<VaultEntry, VaultError> {
236 let idx = self.read_index()?;
237 idx.entries
238 .into_iter()
239 .find(|e| e.name == name && e.project == project)
240 .ok_or_else(|| VaultError::NotFound(format!("{name}@{project}")))
241 }
242
243 pub fn remove(&self, fingerprint: &str) -> Result<(), VaultError> {
244 let mut idx = self.read_index()?;
245 let before = idx.entries.len();
246 idx.entries.retain(|e| e.fingerprint != fingerprint);
247 if idx.entries.len() == before {
248 return Err(VaultError::NotFound(fingerprint.to_string()));
249 }
250 let key_path = self.key_file_path(fingerprint);
251 if key_path.exists() {
252 fs::remove_file(key_path)?;
253 }
254 self.write_index(&idx)?;
255 Ok(())
256 }
257
258 fn key_file_path(&self, fingerprint: &str) -> PathBuf {
259 let safe = fingerprint.replace(':', "_");
262 self.keys_dir().join(format!("{safe}.key"))
263 }
264}
265
266fn encrypt_to_file(path: &Path, plaintext: &[u8], password: &str) -> Result<(), VaultError> {
269 let rng = SystemRandom::new();
270 let mut salt = [0u8; SALT_LEN];
271 rng.fill(&mut salt).expect("rng");
272 let mut nonce_bytes = [0u8; NONCE_LEN];
273 rng.fill(&mut nonce_bytes).expect("rng");
274
275 let mut key = [0u8; KEY_LEN];
276 pbkdf2::derive(
277 pbkdf2::PBKDF2_HMAC_SHA256,
278 NonZeroU32::new(DEFAULT_PBKDF2_ITERS).unwrap(),
279 &salt,
280 password.as_bytes(),
281 &mut key,
282 );
283
284 let unbound = UnboundKey::new(&AES_256_GCM, &key)
285 .map_err(|_| VaultError::Malformed("aead key construction failed"))?;
286 let sealing = LessSafeKey::new(unbound);
287 let mut buf = plaintext.to_vec();
288 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
289 sealing
290 .seal_in_place_append_tag(nonce, Aad::empty(), &mut buf)
291 .map_err(|_| VaultError::Malformed("aead seal failed"))?;
292 key.zeroize();
293
294 let mut file = fs::File::create(path)?;
295 file.write_all(&VAULT_MAGIC)?;
296 file.write_all(&[VAULT_VERSION])?;
297 file.write_all(&salt)?;
298 file.write_all(&nonce_bytes)?;
299 file.write_all(&DEFAULT_PBKDF2_ITERS.to_be_bytes())?;
300 file.write_all(&buf)?;
301 file.flush()?;
302 set_file_perms(path)?;
303 Ok(())
304}
305
306fn decrypt_from_file(path: &Path, password: &str) -> Result<Vec<u8>, VaultError> {
307 let mut file = fs::File::open(path)?;
308 let mut all = Vec::new();
309 file.read_to_end(&mut all)?;
310 if all.len() < HEADER_LEN + 16 {
311 return Err(VaultError::Malformed("vault file shorter than header+tag"));
312 }
313 if all[0..4] != VAULT_MAGIC {
314 return Err(VaultError::InvalidMagic);
315 }
316 if all[4] != VAULT_VERSION {
317 return Err(VaultError::UnsupportedVersion(all[4]));
318 }
319 let mut o = 5usize;
320 let salt = &all[o..o + SALT_LEN];
321 o += SALT_LEN;
322 let nonce_bytes: [u8; NONCE_LEN] = all[o..o + NONCE_LEN].try_into().unwrap();
323 o += NONCE_LEN;
324 let iters = u32::from_be_bytes(all[o..o + 4].try_into().unwrap());
325 o += 4;
326 if iters < MIN_PBKDF2_ITERS {
327 return Err(VaultError::IterationsTooLow { got: iters });
328 }
329 let mut ciphertext = all[o..].to_vec();
330
331 let mut key = [0u8; KEY_LEN];
332 pbkdf2::derive(
333 pbkdf2::PBKDF2_HMAC_SHA256,
334 NonZeroU32::new(iters).ok_or(VaultError::Malformed("zero iters"))?,
335 salt,
336 password.as_bytes(),
337 &mut key,
338 );
339 let unbound = UnboundKey::new(&AES_256_GCM, &key)
340 .map_err(|_| VaultError::Malformed("aead key construction failed"))?;
341 let opening = LessSafeKey::new(unbound);
342 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
343 let plaintext = opening
344 .open_in_place(nonce, Aad::empty(), &mut ciphertext)
345 .map_err(|_| VaultError::DecryptionFailed)?;
346 let result = plaintext.to_vec();
347 key.zeroize();
348 Ok(result)
349}
350
351fn set_dir_perms(path: &Path) -> Result<(), VaultError> {
352 #[cfg(unix)]
353 {
354 use std::os::unix::fs::PermissionsExt;
355 if path.exists() {
356 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
357 }
358 }
359 let _ = path;
360 Ok(())
361}
362
363fn set_file_perms(path: &Path) -> Result<(), VaultError> {
364 #[cfg(unix)]
365 {
366 use std::os::unix::fs::PermissionsExt;
367 if path.exists() {
368 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
369 }
370 }
371 let _ = path;
372 Ok(())
373}
374
375fn now_secs() -> i64 {
376 use std::time::{SystemTime, UNIX_EPOCH};
377 SystemTime::now()
378 .duration_since(UNIX_EPOCH)
379 .map(|d| d.as_secs() as i64)
380 .unwrap_or(0)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::sync::atomic::{AtomicU32, Ordering};
387
388 static COUNTER: AtomicU32 = AtomicU32::new(0);
389
390 fn temp_root() -> PathBuf {
391 let mut p = std::env::temp_dir();
392 let pid = std::process::id();
393 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
394 p.push(format!("agentid-vault-test-{pid}-{n}"));
395 let _ = fs::remove_dir_all(&p);
396 p
397 }
398
399 #[test]
400 fn init_creates_index() {
401 let root = temp_root();
402 let v = Vault::new(&root);
403 v.init().unwrap();
404 assert!(v.is_initialized());
405 assert!(v.read_index().unwrap().entries.is_empty());
406 fs::remove_dir_all(&root).ok();
407 }
408
409 #[test]
410 fn store_and_load_round_trip() {
411 let root = temp_root();
412 let v = Vault::new(&root);
413 v.init().unwrap();
414 let id = AgentIdentity::derive("bot", "proj", None).unwrap();
415 let entry = v.store(&id, "correct horse battery staple").unwrap();
416 assert_eq!(entry.fingerprint, id.fingerprint());
417
418 let loaded = v.load(&id.fingerprint(), "correct horse battery staple").unwrap();
419 assert_eq!(loaded.public_key(), id.public_key());
420 assert_eq!(loaded.name, "bot");
421 fs::remove_dir_all(&root).ok();
422 }
423
424 #[test]
425 fn wrong_password_fails() {
426 let root = temp_root();
427 let v = Vault::new(&root);
428 v.init().unwrap();
429 let id = AgentIdentity::derive("bot", "proj", None).unwrap();
430 v.store(&id, "right").unwrap();
431 assert!(matches!(
432 v.load(&id.fingerprint(), "wrong"),
433 Err(VaultError::DecryptionFailed)
434 ));
435 fs::remove_dir_all(&root).ok();
436 }
437
438 #[test]
439 fn duplicate_store_rejected() {
440 let root = temp_root();
441 let v = Vault::new(&root);
442 v.init().unwrap();
443 let id = AgentIdentity::derive("bot", "proj", None).unwrap();
444 v.store(&id, "pw").unwrap();
445 assert!(matches!(v.store(&id, "pw"), Err(VaultError::AlreadyExists(_))));
446 fs::remove_dir_all(&root).ok();
447 }
448
449 #[test]
450 fn remove_works() {
451 let root = temp_root();
452 let v = Vault::new(&root);
453 v.init().unwrap();
454 let id = AgentIdentity::derive("bot", "proj", None).unwrap();
455 v.store(&id, "pw").unwrap();
456 v.remove(&id.fingerprint()).unwrap();
457 assert!(v.list().unwrap().is_empty());
458 fs::remove_dir_all(&root).ok();
459 }
460}