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, ¶ms)?;
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}