Skip to main content

brainos_vault/
file.rs

1//! Encrypted-file fallback backend.
2//!
3//! Layout under `<dir>/`:
4//! ```text
5//! <dir>/
6//!   .verifier           — Argon2id hash of passphrase (PHC string)
7//!   <tool>/<key>.enc    — one encrypted blob per entry (nonce || ciphertext)
8//!   <tool>/<key>.meta   — sidecar JSON: { shape, created_at, last_used_at }
9//! ```
10//!
11//! Encryption: AES-256-GCM with 96-bit random nonce per write. Key derived
12//! from passphrase via Argon2id (46 MiB, t=1, p=1) using a per-vault salt
13//! stored alongside the verifier.
14
15use std::path::{Path, PathBuf};
16
17use aes_gcm::aead::{Aead, KeyInit};
18use aes_gcm::{Aes256Gcm, Key, Nonce};
19use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
20use argon2::{Algorithm, Argon2, Params, Version};
21use chrono::Utc;
22use rand::rngs::OsRng;
23use rand::RngCore;
24use serde::{Deserialize, Serialize};
25use tokio::fs;
26use zeroize::Zeroizing;
27
28use crate::inject::{CredentialMetadata, CredentialValue, InjectedCredential, InjectionShape};
29use crate::vault::VaultError;
30
31const NONCE_LEN: usize = 12;
32const KEY_LEN: usize = 32;
33const VERIFIER_FILE: &str = ".verifier";
34const SALT_FILE: &str = ".salt";
35
36/// Argon2id parameters — OWASP 2024 minimum recommendation for password
37/// hashing on commodity hardware (Issue 60). 46 MiB memory, t=1
38/// iteration, parallelism=1. Bumped from the v0.4.0 value
39/// (19 MiB, t=2, p=1) which was still inside OWASP's older range but
40/// now sits below the 2024 floor. Higher memory raises GPU/ASIC cost
41/// disproportionately to CPU cost on the legitimate single-user path.
42fn argon2_params() -> Params {
43    Params::new(46 * 1024, 1, 1, Some(KEY_LEN)).expect("valid argon2 params")
44}
45
46/// Source of the fallback passphrase.
47#[derive(Debug, Clone)]
48pub enum PassphraseSource {
49    /// Direct passphrase (for tests / programmatic init).
50    Direct(String),
51    /// Read from environment variable (default: `BRAIN_VAULT_PASSPHRASE`).
52    EnvVar(String),
53    /// Read from a file path.
54    File(PathBuf),
55    /// Prompt on the TTY via `rpassword`.
56    Prompt,
57}
58
59impl PassphraseSource {
60    pub fn resolve(&self) -> Result<String, VaultError> {
61        match self {
62            PassphraseSource::Direct(s) => Ok(s.clone()),
63            PassphraseSource::EnvVar(name) => {
64                std::env::var(name).map_err(|_| VaultError::PassphraseMissing)
65            }
66            PassphraseSource::File(path) => {
67                let raw = std::fs::read_to_string(path)?;
68                Ok(raw.trim_end_matches(['\n', '\r']).to_string())
69            }
70            PassphraseSource::Prompt => {
71                rpassword::prompt_password("Vault passphrase: ").map_err(VaultError::Io)
72            }
73        }
74    }
75}
76
77/// Encrypted-file backend.
78pub struct FileBackend {
79    dir: PathBuf,
80    passphrase: PassphraseSource,
81}
82
83impl FileBackend {
84    pub fn new(dir: PathBuf, passphrase: PassphraseSource) -> Self {
85        Self { dir, passphrase }
86    }
87
88    /// Initialise the vault directory and write the passphrase verifier.
89    /// Idempotent: re-running with the same passphrase is a no-op; a
90    /// different passphrase returns `BadPassphrase`.
91    pub async fn init(&self) -> Result<(), VaultError> {
92        fs::create_dir_all(&self.dir).await?;
93
94        let verifier_path = self.dir.join(VERIFIER_FILE);
95        let salt_path = self.dir.join(SALT_FILE);
96
97        // Issue 61: zeroize on drop.
98        let passphrase = Zeroizing::new(self.passphrase.resolve()?);
99
100        if verifier_path.exists() {
101            // Verify the supplied passphrase matches the stored verifier.
102            let hash_str = fs::read_to_string(&verifier_path).await?;
103            let parsed = PasswordHash::new(&hash_str)
104                .map_err(|e| VaultError::InvalidData(format!("verifier parse: {e}")))?;
105            Argon2::default()
106                .verify_password(passphrase.as_bytes(), &parsed)
107                .map_err(|_| VaultError::BadPassphrase)?;
108            return Ok(());
109        }
110
111        // First-time init: write verifier + salt.
112        let salt = SaltString::generate(&mut OsRng);
113        let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
114        let hash = argon
115            .hash_password(passphrase.as_bytes(), &salt)
116            .map_err(|e| VaultError::Crypto(format!("hash_password: {e}")))?
117            .to_string();
118        fs::write(&verifier_path, hash).await?;
119
120        // Persist salt for key derivation (distinct from verifier salt — we
121        // use one salt for the derivation key across all entries).
122        let mut salt_bytes = [0u8; 16];
123        OsRng.fill_bytes(&mut salt_bytes);
124        fs::write(&salt_path, salt_bytes).await?;
125
126        Ok(())
127    }
128
129    /// Ensure verifier exists; returns `BadPassphrase` if it doesn't match.
130    /// Issue 61: the resolved passphrase is wrapped in `Zeroizing` so it
131    /// is scrubbed from memory when the binding drops.
132    async fn require_verified(&self) -> Result<Zeroizing<String>, VaultError> {
133        let verifier_path = self.dir.join(VERIFIER_FILE);
134        if !verifier_path.exists() {
135            return Err(VaultError::BackendUnavailable(format!(
136                "vault not initialised at {} — run `brain vault init`",
137                self.dir.display()
138            )));
139        }
140        let passphrase = Zeroizing::new(self.passphrase.resolve()?);
141        let hash_str = fs::read_to_string(&verifier_path).await?;
142        let parsed = PasswordHash::new(&hash_str)
143            .map_err(|e| VaultError::InvalidData(format!("verifier parse: {e}")))?;
144        Argon2::default()
145            .verify_password(passphrase.as_bytes(), &parsed)
146            .map_err(|_| VaultError::BadPassphrase)?;
147        Ok(passphrase)
148    }
149
150    /// Issue 61: derived key is wrapped in `Zeroizing` so its bytes are
151    /// scrubbed from RAM when the value is dropped. Callers should keep
152    /// the returned binding scoped tightly around the AES-GCM call.
153    async fn derive_key(&self, passphrase: &str) -> Result<Zeroizing<[u8; KEY_LEN]>, VaultError> {
154        let salt_path = self.dir.join(SALT_FILE);
155        let salt = fs::read(&salt_path).await?;
156        let mut key = Zeroizing::new([0u8; KEY_LEN]);
157        let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
158        argon
159            .hash_password_into(passphrase.as_bytes(), &salt, key.as_mut())
160            .map_err(|e| VaultError::Crypto(format!("derive: {e}")))?;
161        Ok(key)
162    }
163
164    fn entry_paths(&self, tool: &str, key: &str) -> (PathBuf, PathBuf) {
165        let base = self.dir.join(sanitize(tool));
166        let name = sanitize(key);
167        (
168            base.join(format!("{name}.enc")),
169            base.join(format!("{name}.meta")),
170        )
171    }
172
173    pub async fn store(
174        &self,
175        tool: &str,
176        key: &str,
177        value: CredentialValue,
178        shape: InjectionShape,
179    ) -> Result<(), VaultError> {
180        let passphrase = self.require_verified().await?;
181        let derived = self.derive_key(&passphrase).await?;
182        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(derived.as_slice()));
183
184        let mut nonce_bytes = [0u8; NONCE_LEN];
185        OsRng.fill_bytes(&mut nonce_bytes);
186        let nonce = Nonce::from_slice(&nonce_bytes);
187
188        let ciphertext = cipher
189            .encrypt(nonce, value.as_str().as_bytes())
190            .map_err(|e| VaultError::Crypto(format!("encrypt: {e}")))?;
191
192        let (enc_path, meta_path) = self.entry_paths(tool, key);
193        if let Some(parent) = enc_path.parent() {
194            fs::create_dir_all(parent).await?;
195        }
196
197        let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
198        blob.extend_from_slice(&nonce_bytes);
199        blob.extend_from_slice(&ciphertext);
200        fs::write(&enc_path, &blob).await?;
201
202        let now = Utc::now().to_rfc3339();
203        let meta = StoredMeta {
204            shape,
205            created_at: now.clone(),
206            last_used_at: None,
207        };
208        fs::write(&meta_path, serde_json::to_vec_pretty(&meta).unwrap()).await?;
209        Ok(())
210    }
211
212    pub async fn get(&self, tool: &str, key: &str) -> Result<InjectedCredential, VaultError> {
213        let passphrase = self.require_verified().await?;
214        let derived = self.derive_key(&passphrase).await?;
215        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(derived.as_slice()));
216
217        let (enc_path, meta_path) = self.entry_paths(tool, key);
218        if !enc_path.exists() {
219            return Err(VaultError::NotFound {
220                tool: tool.to_string(),
221                key: key.to_string(),
222            });
223        }
224        let blob = fs::read(&enc_path).await?;
225        if blob.len() <= NONCE_LEN {
226            return Err(VaultError::InvalidData(format!(
227                "blob too short: {} bytes",
228                blob.len()
229            )));
230        }
231        let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
232        let plaintext = cipher
233            .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
234            .map_err(|e| VaultError::Crypto(format!("decrypt: {e}")))?;
235        let value = String::from_utf8(plaintext)
236            .map_err(|e| VaultError::InvalidData(format!("utf8: {e}")))?;
237
238        let meta: StoredMeta = serde_json::from_slice(&fs::read(&meta_path).await?)
239            .map_err(|e| VaultError::InvalidData(format!("meta: {e}")))?;
240
241        // Update last_used_at best-effort.
242        let updated = StoredMeta {
243            last_used_at: Some(Utc::now().to_rfc3339()),
244            ..meta.clone()
245        };
246        if let Err(err) = fs::write(
247            &meta_path,
248            serde_json::to_vec_pretty(&updated).unwrap_or_default(),
249        )
250        .await
251        {
252            tracing::warn!(error = %err, "vault: failed to update last_used_at");
253        }
254
255        Ok(InjectedCredential {
256            shape: meta.shape,
257            value: CredentialValue::new(value),
258        })
259    }
260
261    pub async fn delete(&self, tool: &str, key: &str) -> Result<(), VaultError> {
262        let (enc_path, meta_path) = self.entry_paths(tool, key);
263        if !enc_path.exists() {
264            return Err(VaultError::NotFound {
265                tool: tool.to_string(),
266                key: key.to_string(),
267            });
268        }
269        fs::remove_file(&enc_path).await?;
270        if meta_path.exists() {
271            let _ = fs::remove_file(&meta_path).await;
272        }
273        Ok(())
274    }
275
276    pub async fn list(&self, tool: Option<&str>) -> Result<Vec<CredentialMetadata>, VaultError> {
277        let mut out = Vec::new();
278        if !self.dir.exists() {
279            return Ok(out);
280        }
281        let mut tool_dirs = fs::read_dir(&self.dir).await?;
282        while let Some(entry) = tool_dirs.next_entry().await? {
283            let path = entry.path();
284            if !path.is_dir() {
285                continue;
286            }
287            let tool_name = match path.file_name().and_then(|n| n.to_str()) {
288                Some(s) => s.to_string(),
289                None => continue,
290            };
291            if let Some(filter) = tool {
292                if tool_name != sanitize(filter) && tool_name != filter {
293                    continue;
294                }
295            }
296            if let Err(err) = collect_entries(&path, &tool_name, &mut out).await {
297                tracing::warn!(tool = %tool_name, error = %err, "vault: list failed for tool dir");
298            }
299        }
300        Ok(out)
301    }
302}
303
304async fn collect_entries(
305    tool_dir: &Path,
306    tool_name: &str,
307    out: &mut Vec<CredentialMetadata>,
308) -> Result<(), VaultError> {
309    let mut entries = fs::read_dir(tool_dir).await?;
310    while let Some(entry) = entries.next_entry().await? {
311        let path = entry.path();
312        if path.extension().and_then(|e| e.to_str()) != Some("meta") {
313            continue;
314        }
315        let key_name = match path
316            .file_stem()
317            .and_then(|n| n.to_str())
318            .map(|s| s.to_string())
319        {
320            Some(s) => s,
321            None => continue,
322        };
323        let raw = fs::read(&path).await?;
324        let meta: StoredMeta = serde_json::from_slice(&raw)
325            .map_err(|e| VaultError::InvalidData(format!("meta: {e}")))?;
326        out.push(CredentialMetadata {
327            tool: tool_name.to_string(),
328            key: key_name,
329            backend: "file".to_string(),
330            created_at: meta.created_at,
331            last_used_at: meta.last_used_at,
332            shape: meta.shape,
333        });
334    }
335    Ok(())
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339struct StoredMeta {
340    shape: InjectionShape,
341    created_at: String,
342    last_used_at: Option<String>,
343}
344
345/// Sanitize tool/key names for use as filesystem components.
346/// Replaces anything outside `[A-Za-z0-9._-]` with `_`.
347///
348/// The allowlist keeps `.`, which alone would let the traversal components
349/// `.` and `..` through — and `entry_paths` joins the sanitized *tool* name
350/// onto the vault dir with no suffix, so a tool literally named `..` would
351/// escape one level up. An empty name would also collapse onto the vault dir
352/// itself. Both cases are neutralized to a safe `Normal` component.
353fn sanitize(s: &str) -> String {
354    let mapped: String = s
355        .chars()
356        .map(|c| {
357            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
358                c
359            } else {
360                '_'
361            }
362        })
363        .collect();
364
365    // "", ".", and ".." are the only mapped results that aren't a single
366    // ordinary path component. Prefix them so they stay inside the vault dir.
367    if mapped.is_empty() || mapped == "." || mapped == ".." {
368        format!("_{mapped}")
369    } else {
370        mapped
371    }
372}
373
374#[cfg(test)]
375mod sanitize_tests {
376    use super::sanitize;
377    use proptest::prelude::*;
378    use std::path::{Component, Path};
379
380    /// Fragments weighted toward path metacharacters an attacker-controlled
381    /// tool/key name might carry: traversal dots, separators, NUL, normal text.
382    fn path_fragment() -> impl Strategy<Value = String> {
383        prop_oneof![
384            Just(".".to_string()),
385            Just("..".to_string()),
386            Just("/".to_string()),
387            Just("\\".to_string()),
388            Just("\0".to_string()),
389            Just("~".to_string()),
390            "[A-Za-z0-9._-]{0,6}",
391            ".*", // arbitrary, including unicode and control bytes
392        ]
393    }
394
395    fn hostile_name(max: usize) -> impl Strategy<Value = String> {
396        proptest::collection::vec(path_fragment(), 0..max).prop_map(|f| f.concat())
397    }
398
399    proptest! {
400        #![proptest_config(ProptestConfig { cases: 512, .. ProptestConfig::default() })]
401
402        /// Whatever the input, the sanitized name is always exactly one
403        /// ordinary path component — never empty, never `.`/`..`, never a
404        /// separator — so joining it onto the vault dir can neither traverse
405        /// out of it nor collapse onto the dir itself.
406        #[test]
407        fn sanitize_yields_one_safe_component(s in hostile_name(12)) {
408            let out = sanitize(&s);
409
410            prop_assert!(!out.is_empty(), "empty component for {s:?}");
411            prop_assert!(
412                out.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
413                "illegal char survived for {s:?}: {out:?}"
414            );
415
416            let comps: Vec<Component> = Path::new(&out).components().collect();
417            prop_assert_eq!(comps.len(), 1, "not a single component for {:?}: {:?}", s, out);
418            prop_assert!(
419                matches!(comps[0], Component::Normal(_)),
420                "non-Normal component (traversal/root) for {s:?}: {out:?}"
421            );
422        }
423
424        /// The bytes the tool/key actually appears under stay inside the
425        /// vault dir: `dir.join(sanitize(name))` is always a direct child.
426        #[test]
427        fn sanitized_join_stays_under_dir(s in hostile_name(12)) {
428            let dir = Path::new("/vault/root");
429            let joined = dir.join(sanitize(&s));
430            prop_assert_eq!(joined.parent(), Some(dir), "escaped vault dir for {:?}", s);
431        }
432    }
433}