Skip to main content

agentic_connect/engine/
vault.rs

1//! Credential vault — encrypted storage for auth credentials.
2
3use ring::aead;
4use ring::rand::{SecureRandom, SystemRandom};
5use std::collections::HashMap;
6
7use crate::types::{ConnectError, ConnectResult, StoredCredential};
8
9/// Encrypted credential vault using AES-256-GCM.
10pub struct CredentialVault {
11    credentials: HashMap<String, StoredCredential>,
12    encryption_key: Option<aead::LessSafeKey>,
13}
14
15impl CredentialVault {
16    /// Create a new vault (unencrypted in-memory for now).
17    pub fn new() -> Self {
18        Self {
19            credentials: HashMap::new(),
20            encryption_key: None,
21        }
22    }
23
24    /// Create a vault with encryption enabled.
25    pub fn with_encryption(passphrase: &str) -> ConnectResult<Self> {
26        let key = derive_key(passphrase)?;
27        Ok(Self {
28            credentials: HashMap::new(),
29            encryption_key: Some(key),
30        })
31    }
32
33    /// Store a credential.
34    pub fn store(&mut self, cred: StoredCredential) {
35        self.credentials.insert(cred.name.clone(), cred);
36    }
37
38    /// Retrieve a credential by name.
39    pub fn retrieve(&self, name: &str) -> Option<&StoredCredential> {
40        self.credentials.get(name)
41    }
42
43    /// Delete a credential by name.
44    pub fn delete(&mut self, name: &str) -> bool {
45        self.credentials.remove(name).is_some()
46    }
47
48    /// List all credential names (never returns actual secrets).
49    pub fn list(&self) -> Vec<CredentialSummary> {
50        self.credentials.values().map(|c| CredentialSummary {
51            name: c.name.clone(),
52            auth_type: c.auth.method_name().to_string(),
53            created_at: c.created_at.to_rfc3339(),
54            tags: c.tags.clone(),
55        }).collect()
56    }
57
58    /// Number of stored credentials.
59    pub fn count(&self) -> usize {
60        self.credentials.len()
61    }
62
63    /// Encrypt a plaintext value.
64    pub fn encrypt(&self, plaintext: &[u8]) -> ConnectResult<Vec<u8>> {
65        let key = self.encryption_key.as_ref()
66            .ok_or_else(|| ConnectError::EncryptionError("No encryption key set".into()))?;
67
68        let rng = SystemRandom::new();
69        let mut nonce_bytes = [0u8; 12];
70        rng.fill(&mut nonce_bytes)
71            .map_err(|_| ConnectError::EncryptionError("RNG failure".into()))?;
72
73        let nonce = aead::Nonce::assume_unique_for_key(nonce_bytes);
74        let mut in_out = plaintext.to_vec();
75
76        key.seal_in_place_append_tag(nonce, aead::Aad::empty(), &mut in_out)
77            .map_err(|_| ConnectError::EncryptionError("Encryption failed".into()))?;
78
79        // Prepend nonce to ciphertext
80        let mut result = nonce_bytes.to_vec();
81        result.extend(in_out);
82        Ok(result)
83    }
84
85    /// Decrypt a ciphertext value.
86    pub fn decrypt(&self, ciphertext: &[u8]) -> ConnectResult<Vec<u8>> {
87        let key = self.encryption_key.as_ref()
88            .ok_or_else(|| ConnectError::EncryptionError("No encryption key set".into()))?;
89
90        if ciphertext.len() < 12 {
91            return Err(ConnectError::EncryptionError("Ciphertext too short".into()));
92        }
93
94        let (nonce_bytes, encrypted) = ciphertext.split_at(12);
95        let nonce = aead::Nonce::try_assume_unique_for_key(nonce_bytes)
96            .map_err(|_| ConnectError::EncryptionError("Invalid nonce".into()))?;
97
98        let mut in_out = encrypted.to_vec();
99        let plaintext = key.open_in_place(nonce, aead::Aad::empty(), &mut in_out)
100            .map_err(|_| ConnectError::EncryptionError("Decryption failed".into()))?;
101
102        Ok(plaintext.to_vec())
103    }
104}
105
106/// Summary of a credential (safe to display — no secrets).
107#[derive(Debug, Clone, serde::Serialize)]
108pub struct CredentialSummary {
109    pub name: String,
110    pub auth_type: String,
111    pub created_at: String,
112    pub tags: Vec<String>,
113}
114
115/// Derive an AES-256-GCM key from a passphrase using PBKDF2.
116fn derive_key(passphrase: &str) -> ConnectResult<aead::LessSafeKey> {
117    use ring::pbkdf2;
118
119    let salt = b"agentic-connect-vault-v1"; // Fixed salt for deterministic derivation
120    let mut key_bytes = [0u8; 32];
121    pbkdf2::derive(
122        pbkdf2::PBKDF2_HMAC_SHA256,
123        std::num::NonZeroU32::new(100_000).unwrap(),
124        salt,
125        passphrase.as_bytes(),
126        &mut key_bytes,
127    );
128
129    let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, &key_bytes)
130        .map_err(|_| ConnectError::EncryptionError("Key derivation failed".into()))?;
131
132    Ok(aead::LessSafeKey::new(unbound))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::types::auth::AuthMethod;
139    use chrono::Utc;
140
141    #[test]
142    fn test_store_and_retrieve() {
143        let mut vault = CredentialVault::new();
144        let cred = StoredCredential {
145            id: uuid::Uuid::new_v4(),
146            name: "test-api".into(),
147            auth: AuthMethod::Bearer { token: "test-token-123".into() },
148            created_at: Utc::now(),
149            last_rotated: None,
150            tags: vec!["prod".into()],
151        };
152        vault.store(cred);
153        assert_eq!(vault.count(), 1);
154        let retrieved = vault.retrieve("test-api").unwrap();
155        assert_eq!(retrieved.name, "test-api");
156    }
157
158    #[test]
159    fn test_delete() {
160        let mut vault = CredentialVault::new();
161        let cred = StoredCredential {
162            id: uuid::Uuid::new_v4(), name: "temp".into(),
163            auth: AuthMethod::None, created_at: Utc::now(),
164            last_rotated: None, tags: vec![],
165        };
166        vault.store(cred);
167        assert!(vault.delete("temp"));
168        assert_eq!(vault.count(), 0);
169    }
170
171    #[test]
172    fn test_encrypt_decrypt_roundtrip() {
173        let vault = CredentialVault::with_encryption("my-secret-passphrase").unwrap();
174        let plaintext = b"sensitive-api-key-12345";
175        let ciphertext = vault.encrypt(plaintext).unwrap();
176        assert_ne!(&ciphertext, plaintext);
177        let decrypted = vault.decrypt(&ciphertext).unwrap();
178        assert_eq!(decrypted, plaintext);
179    }
180
181    #[test]
182    fn test_list_hides_secrets() {
183        let mut vault = CredentialVault::new();
184        vault.store(StoredCredential {
185            id: uuid::Uuid::new_v4(), name: "secret-key".into(),
186            auth: AuthMethod::Bearer { token: "SUPER_SECRET".into() },
187            created_at: Utc::now(), last_rotated: None, tags: vec![],
188        });
189        let list = vault.list();
190        assert_eq!(list.len(), 1);
191        assert_eq!(list[0].auth_type, "bearer");
192        // The summary should NOT contain the actual token
193        let json = serde_json::to_string(&list[0]).unwrap();
194        assert!(!json.contains("SUPER_SECRET"));
195    }
196}