Skip to main content

agentid_core/
vault.rs

1//! Encrypted on-disk key vault.
2//!
3//! Layout:
4//!
5//! ```text
6//!   ~/.agentid/
7//!     index.json                              # public metadata only
8//!     keys/<sanitised-fingerprint>.key        # AES-256-GCM ciphertext
9//! ```
10//!
11//! ## Per-key file format
12//!
13//! ```text
14//!   off   size   field
15//!   ---   ----   -----
16//!     0      4   magic           = 0xA9 0x1D 0x56 0x01
17//!     4      1   version         = 0x01
18//!     5     16   pbkdf2 salt
19//!    21     12   gcm nonce
20//!    33      4   pbkdf2 iters    (u32 BE)
21//!    37    var   ciphertext || gcm tag
22//! ```
23//!
24//! * KDF: PBKDF2-HMAC-SHA256, default 200 000 iterations.
25//! * Cipher: AES-256-GCM (no AAD — file format is implicit context).
26//! * Plaintext: JSON-encoded [`StoredKey`] (`name + project + secret_hex`).
27//!
28//! Files are written with `0o600`; the `~/.agentid` directory with `0o700`.
29
30use 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/// Public metadata for a vault entry. Safe to log.
80#[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, // hex
86    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/// Plaintext payload encrypted in each key file. Held in memory only briefly.
106#[derive(Serialize, Deserialize, Zeroize)]
107#[zeroize(drop)]
108struct StoredKey {
109    name: String,
110    project: String,
111    /// Hex-encoded 32-byte Ed25519 secret key.
112    secret_hex: String,
113    created_at: i64,
114}
115
116/// Vault rooted at a directory (typically `~/.agentid`).
117pub struct Vault {
118    root: PathBuf,
119}
120
121impl Vault {
122    /// Default vault root: `$HOME/.agentid`.
123    pub fn default_root() -> Result<PathBuf, VaultError> {
124        Ok(dirs::home_dir().ok_or(VaultError::NoHome)?.join(".agentid"))
125    }
126
127    /// Open a vault at the given root.
128    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    /// Initialise the vault directory tree and a fresh empty index.
145    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    /// Read the public index. Returns [`VaultError::NotInitialized`] if the
160    /// vault has never been `init`'d.
161    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    /// Public view of all stored identities.
177    pub fn list(&self) -> Result<Vec<VaultEntry>, VaultError> {
178        Ok(self.read_index()?.entries)
179    }
180
181    /// Persist an identity under `password`.
182    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        // plaintext is now safe to drop; serde_json::to_vec returned a fresh Vec
207        drop(plaintext);
208
209        idx.entries.push(entry.clone());
210        self.write_index(&idx)?;
211        Ok(entry)
212    }
213
214    /// Decrypt and load an identity by fingerprint.
215    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    /// Resolve a fingerprint by `name@project`. Convenience for the CLI.
231    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        // Replace ':' for filesystems that disallow it (Windows). The
260        // fingerprint shape is always `ag:sha256:<16-hex>`.
261        let safe = fingerprint.replace(':', "_");
262        self.keys_dir().join(format!("{safe}.key"))
263    }
264}
265
266// ---- file encryption ----
267
268fn 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}