1use 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
36fn argon2_params() -> Params {
43 Params::new(46 * 1024, 1, 1, Some(KEY_LEN)).expect("valid argon2 params")
44}
45
46#[derive(Debug, Clone)]
48pub enum PassphraseSource {
49 Direct(String),
51 EnvVar(String),
53 File(PathBuf),
55 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
77pub 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 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 let passphrase = Zeroizing::new(self.passphrase.resolve()?);
99
100 if verifier_path.exists() {
101 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 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 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 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 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 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
345fn 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 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 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 ".*", ]
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 #[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 #[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}