Skip to main content

envvault/vault/
store.rs

1//! High-level vault operations used by CLI commands.
2//!
3//! `VaultStore` wraps the binary format layer and the crypto layer so
4//! that the rest of the application can work with simple method calls
5//! like `store.set_secret("DB_URL", "postgres://...")`.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use chrono::Utc;
11use zeroize::Zeroize;
12
13use crate::crypto::encryption::{decrypt, encrypt};
14use crate::crypto::kdf::{derive_master_key_with_params, generate_salt, Argon2Params};
15use crate::crypto::keyfile;
16use crate::crypto::keys::MasterKey;
17use crate::errors::{EnvVaultError, Result};
18
19use super::format::{self, StoredArgon2Params, VaultHeader, CURRENT_VERSION};
20use super::secret::{Secret, SecretMetadata};
21
22/// The main vault handle.  Create one with `VaultStore::create` or
23/// `VaultStore::open`, then use its methods to manage secrets.
24pub struct VaultStore {
25    /// Path to the `.vault` file on disk.
26    path: PathBuf,
27
28    /// Header metadata (version, salt, environment, timestamps).
29    header: VaultHeader,
30
31    /// In-memory map of secret name -> encrypted Secret.
32    secrets: HashMap<String, Secret>,
33
34    /// The derived master key (zeroized on drop).
35    master_key: MasterKey,
36}
37
38impl VaultStore {
39    // ------------------------------------------------------------------
40    // Construction
41    // ------------------------------------------------------------------
42
43    /// Create a brand-new vault file at `path`.
44    ///
45    /// Generates a random salt, derives the master key from the
46    /// password, and writes an empty vault to disk.
47    ///
48    /// Pass `None` for `argon2_params` to use sensible defaults.
49    /// Pass `Some(settings.argon2_params())` to use config values.
50    ///
51    /// Pass `Some(bytes)` for `keyfile_bytes` to enable keyfile-based 2FA.
52    /// The keyfile hash is stored in the vault header so `open` can
53    /// verify the correct keyfile is used.
54    pub fn create(
55        path: &Path,
56        password: &[u8],
57        environment: &str,
58        argon2_params: Option<&Argon2Params>,
59        keyfile_bytes: Option<&[u8]>,
60    ) -> Result<Self> {
61        if path.exists() {
62            return Err(EnvVaultError::VaultAlreadyExists(path.to_path_buf()));
63        }
64
65        // 1. Generate a random salt.
66        let salt = generate_salt();
67
68        // 2. Resolve Argon2 params (explicit or defaults).
69        let effective_params = argon2_params.copied().unwrap_or_default();
70
71        // 3. Combine password with keyfile (if provided) and derive master key.
72        let mut effective_password = match keyfile_bytes {
73            Some(kf) => keyfile::combine_password_keyfile(password, kf)?,
74            None => password.to_vec(),
75        };
76        let mut master_bytes =
77            derive_master_key_with_params(&effective_password, &salt, &effective_params)?;
78        effective_password.zeroize();
79        let master_key = MasterKey::new(master_bytes);
80        master_bytes.zeroize();
81
82        // 4. Build the header (store the params so open uses the same).
83        let kf_hash = keyfile_bytes.map(keyfile::hash_keyfile);
84        let header = VaultHeader {
85            version: CURRENT_VERSION,
86            salt: salt.to_vec(),
87            created_at: Utc::now(),
88            environment: environment.to_string(),
89            argon2_params: Some(StoredArgon2Params {
90                memory_kib: effective_params.memory_kib,
91                iterations: effective_params.iterations,
92                parallelism: effective_params.parallelism,
93            }),
94            keyfile_hash: kf_hash,
95        };
96
97        // 5. Start with an empty secrets map.
98        let secrets = HashMap::new();
99
100        let mut store = Self {
101            path: path.to_path_buf(),
102            header,
103            secrets,
104            master_key,
105        };
106
107        // 6. Persist the empty vault to disk.
108        store.save()?;
109
110        Ok(store)
111    }
112
113    /// Open an existing vault file, verifying its integrity.
114    ///
115    /// Reads the binary file, derives the master key from the
116    /// password + stored salt (using stored Argon2 params), and
117    /// verifies the HMAC **over the original bytes from disk**.
118    ///
119    /// If the vault was created with a keyfile, `keyfile_bytes` must be
120    /// provided. If the vault has no keyfile requirement, the parameter
121    /// is ignored.
122    pub fn open(path: &Path, password: &[u8], keyfile_bytes: Option<&[u8]>) -> Result<Self> {
123        // 1. Read the binary vault file (raw bytes preserved).
124        let raw = format::read_vault(path)?;
125
126        // 2. Validate keyfile requirement.
127        //    If the vault header has a keyfile_hash, a keyfile is required.
128        if let Some(ref expected_hash) = raw.header.keyfile_hash {
129            match keyfile_bytes {
130                Some(kf) => keyfile::verify_keyfile_hash(kf, expected_hash)?,
131                None => {
132                    return Err(EnvVaultError::KeyfileError(
133                        "this vault requires a keyfile — use --keyfile <path>".into(),
134                    ));
135                }
136            }
137        }
138
139        // 3. Combine password with keyfile (if provided) and derive master key.
140        let mut effective_password = match keyfile_bytes {
141            Some(kf) => keyfile::combine_password_keyfile(password, kf)?,
142            None => password.to_vec(),
143        };
144
145        // 4. Derive the master key using the stored Argon2 params.
146        //    Fall back to defaults for v0.1.0 vaults without stored params.
147        let stored = raw.header.argon2_params.unwrap_or_default();
148        let params = Argon2Params {
149            memory_kib: stored.memory_kib,
150            iterations: stored.iterations,
151            parallelism: stored.parallelism,
152        };
153        let mut master_bytes =
154            derive_master_key_with_params(&effective_password, &raw.header.salt, &params)?;
155        effective_password.zeroize();
156        let master_key = MasterKey::new(master_bytes);
157        master_bytes.zeroize();
158
159        // 3. Verify the HMAC over the *original raw bytes* from disk.
160        //    This avoids the re-serialization round-trip bug where
161        //    serde_json might produce different byte output.
162        let mut hmac_key = master_key.derive_hmac_key()?;
163        format::verify_hmac(
164            &hmac_key,
165            &raw.header_bytes,
166            &raw.secrets_bytes,
167            &raw.stored_hmac,
168        )?;
169        hmac_key.zeroize();
170
171        // 4. Build the in-memory map.
172        let secrets: HashMap<String, Secret> = raw
173            .secrets
174            .into_iter()
175            .map(|s| (s.name.clone(), s))
176            .collect();
177
178        Ok(Self {
179            path: path.to_path_buf(),
180            header: raw.header,
181            secrets,
182            master_key,
183        })
184    }
185
186    /// Build a `VaultStore` from pre-constructed parts.
187    ///
188    /// Used by `rotate-key` to create a new store with a new master key
189    /// without writing to disk first.
190    pub fn from_parts(path: PathBuf, header: VaultHeader, master_key: MasterKey) -> Self {
191        Self {
192            path,
193            header,
194            secrets: HashMap::new(),
195            master_key,
196        }
197    }
198
199    // ------------------------------------------------------------------
200    // Secret operations
201    // ------------------------------------------------------------------
202
203    /// Add or update a secret.
204    ///
205    /// The plaintext value is encrypted with a per-secret key derived
206    /// from the master key + secret name.  The per-secret key is
207    /// zeroized immediately after use.
208    pub fn set_secret(&mut self, name: &str, plaintext_value: &str) -> Result<()> {
209        Self::validate_secret_name(name)?;
210
211        // Derive a unique encryption key for this secret name.
212        let mut secret_key = self.master_key.derive_secret_key(name)?;
213
214        // Encrypt the plaintext value.
215        let encrypted_value = encrypt(&secret_key, plaintext_value.as_bytes());
216
217        // Zeroize the per-secret key immediately — we no longer need it.
218        secret_key.zeroize();
219
220        let encrypted_value = encrypted_value?;
221
222        let now = Utc::now();
223
224        // If the secret already exists, preserve the original created_at.
225        let created_at = self
226            .secrets
227            .get(name)
228            .map_or(now, |existing| existing.created_at);
229
230        let secret = Secret {
231            name: name.to_string(),
232            encrypted_value,
233            created_at,
234            updated_at: now,
235        };
236
237        self.secrets.insert(name.to_string(), secret);
238        Ok(())
239    }
240
241    /// Decrypt and return the plaintext value of a secret.
242    ///
243    /// The per-secret key is zeroized after decryption.
244    pub fn get_secret(&self, name: &str) -> Result<String> {
245        Self::validate_secret_name(name)?;
246        let secret = self
247            .secrets
248            .get(name)
249            .ok_or_else(|| EnvVaultError::SecretNotFound(name.to_string()))?;
250
251        let mut secret_key = self.master_key.derive_secret_key(name)?;
252        let plaintext_bytes = decrypt(&secret_key, &secret.encrypted_value)?;
253        secret_key.zeroize();
254
255        // Convert to String via from_utf8 which takes ownership (no clone).
256        // On error, zeroize the bytes inside the error before discarding.
257        String::from_utf8(plaintext_bytes).map_err(|e| {
258            let mut bad_bytes = e.into_bytes();
259            bad_bytes.zeroize();
260            EnvVaultError::SerializationError("secret value is not valid UTF-8".to_string())
261        })
262    }
263
264    /// Remove a secret from the vault.
265    pub fn delete_secret(&mut self, name: &str) -> Result<()> {
266        Self::validate_secret_name(name)?;
267        if self.secrets.remove(name).is_none() {
268            return Err(EnvVaultError::SecretNotFound(name.to_string()));
269        }
270        Ok(())
271    }
272
273    /// List metadata for all secrets, sorted by name.
274    pub fn list_secrets(&self) -> Vec<SecretMetadata> {
275        let mut list: Vec<SecretMetadata> = self
276            .secrets
277            .values()
278            .map(|s| SecretMetadata {
279                name: s.name.clone(),
280                created_at: s.created_at,
281                updated_at: s.updated_at,
282            })
283            .collect();
284
285        list.sort_by(|a, b| a.name.cmp(&b.name));
286        list
287    }
288
289    /// Decrypt all secrets and return them as a name -> plaintext map.
290    ///
291    /// Used by the `run` command to inject secrets into a child process.
292    pub fn get_all_secrets(&self) -> Result<HashMap<String, String>> {
293        let mut map = HashMap::with_capacity(self.secrets.len());
294
295        for name in self.secrets.keys() {
296            let value = self.get_secret(name)?;
297            map.insert(name.clone(), value);
298        }
299
300        Ok(map)
301    }
302
303    // ------------------------------------------------------------------
304    // Persistence
305    // ------------------------------------------------------------------
306
307    /// Serialize the vault and write it to disk atomically.
308    ///
309    /// Computes a fresh HMAC over the header + secrets JSON and writes
310    /// the full binary envelope via temp-file + rename.
311    pub fn save(&mut self) -> Result<()> {
312        // Collect secrets into a sorted Vec for deterministic output.
313        let mut secret_list: Vec<Secret> = self.secrets.values().cloned().collect();
314        secret_list.sort_by(|a, b| a.name.cmp(&b.name));
315
316        let mut hmac_key = self.master_key.derive_hmac_key()?;
317
318        format::write_vault(&self.path, &self.header, &secret_list, &hmac_key)?;
319        hmac_key.zeroize();
320
321        Ok(())
322    }
323
324    // ------------------------------------------------------------------
325    // Accessors
326    // ------------------------------------------------------------------
327
328    /// Returns the path to the vault file.
329    pub fn path(&self) -> &Path {
330        &self.path
331    }
332
333    /// Returns the environment name (e.g. "dev").
334    pub fn environment(&self) -> &str {
335        &self.header.environment
336    }
337
338    /// Returns the number of secrets in the vault.
339    pub fn secret_count(&self) -> usize {
340        self.secrets.len()
341    }
342
343    /// Returns the vault creation timestamp.
344    pub fn created_at(&self) -> chrono::DateTime<chrono::Utc> {
345        self.header.created_at
346    }
347
348    /// Returns `true` if the vault contains a secret with the given name.
349    ///
350    /// This is a metadata-only check — no decryption is performed.
351    pub fn contains_key(&self, name: &str) -> bool {
352        self.secrets.contains_key(name)
353    }
354
355    /// Returns a reference to the vault header.
356    ///
357    /// Useful for inspecting stored Argon2 params, keyfile hash, etc.
358    pub fn header(&self) -> &super::format::VaultHeader {
359        &self.header
360    }
361
362    // ------------------------------------------------------------------
363    // Validation
364    // ------------------------------------------------------------------
365
366    /// Validate that a secret name is safe.
367    ///
368    /// Allowed: ASCII letters, digits, underscores, hyphens, periods.
369    /// Must be non-empty and at most 256 characters.
370    fn validate_secret_name(name: &str) -> Result<()> {
371        if name.is_empty() {
372            return Err(EnvVaultError::CommandFailed(
373                "secret name cannot be empty".into(),
374            ));
375        }
376        if name.len() > 256 {
377            return Err(EnvVaultError::CommandFailed(
378                "secret name cannot exceed 256 characters".into(),
379            ));
380        }
381        if !name
382            .bytes()
383            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
384        {
385            return Err(EnvVaultError::CommandFailed(format!(
386                "secret name '{name}' contains invalid characters — only ASCII letters, digits, underscores, hyphens, and periods are allowed"
387            )));
388        }
389        Ok(())
390    }
391}