1use std::collections::BTreeMap;
47use std::io::Write;
48
49use aes_gcm::aead::{AeadCore, AeadInPlace, KeyInit, OsRng, rand_core::RngCore};
50use aes_gcm::{Aes256Gcm, Key, Nonce, Tag};
51use zeroize::Zeroize;
52
53const MAGIC: [u8; 4] = *b"QVLT";
56const VERSION: u8 = 0x01;
57const SALT_LEN: usize = 16;
58const NONCE_LEN: usize = 12;
59const TAG_LEN: usize = 16;
60const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN + TAG_LEN; pub const ITERATIONS: u32 = 600_000;
64
65pub const MAX_KEY_LEN: usize = 256;
67
68pub const MAX_VALUE_LEN: usize = 65536;
70
71#[derive(Debug)]
75pub enum VaultError {
76 TooSmall,
78 BadMagic,
80 UnsupportedVersion(u8),
82 DecryptionFailed,
84 EncryptionFailed,
86 MalformedData,
88 Io(std::io::Error),
90}
91
92impl std::fmt::Display for VaultError {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Self::TooSmall => write!(f, "vault file too small"),
96 Self::BadMagic => write!(f, "invalid vault file (bad magic)"),
97 Self::UnsupportedVersion(v) => write!(f, "unsupported vault version: {v}"),
98 Self::DecryptionFailed => write!(f, "decryption failed (wrong passphrase?)"),
99 Self::EncryptionFailed => write!(f, "encryption failed"),
100 Self::MalformedData => write!(f, "malformed vault data"),
101 Self::Io(e) => write!(f, "I/O error: {e}"),
102 }
103 }
104}
105
106impl std::error::Error for VaultError {}
107
108impl From<std::io::Error> for VaultError {
109 fn from(e: std::io::Error) -> Self {
110 Self::Io(e)
111 }
112}
113
114#[derive(Debug, Clone)]
121pub struct Vault {
122 entries: BTreeMap<String, String>,
123}
124
125impl Vault {
126 pub fn new() -> Self {
128 Self {
129 entries: BTreeMap::new(),
130 }
131 }
132
133 pub fn from_map(entries: BTreeMap<String, String>) -> Self {
135 Self { entries }
136 }
137
138 pub fn get(&self, key: &str) -> Option<&str> {
140 self.entries.get(key).map(|s| s.as_str())
141 }
142
143 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
145 self.entries.insert(key.into(), value.into())
146 }
147
148 pub fn delete(&mut self, key: &str) -> Option<String> {
150 self.entries.remove(key)
151 }
152
153 pub fn keys(&self) -> impl Iterator<Item = &str> {
155 self.entries.keys().map(|s| s.as_str())
156 }
157
158 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
160 self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
161 }
162
163 pub fn len(&self) -> usize {
165 self.entries.len()
166 }
167
168 pub fn is_empty(&self) -> bool {
170 self.entries.is_empty()
171 }
172
173 pub fn entries_mut(&mut self) -> &mut BTreeMap<String, String> {
175 &mut self.entries
176 }
177
178 pub fn to_map(&self) -> BTreeMap<String, String> {
180 self.entries.clone()
181 }
182
183 pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>, VaultError> {
190 let mut plaintext = serialize(&self.entries);
191
192 let mut salt = [0u8; SALT_LEN];
193 OsRng.fill_bytes(&mut salt);
194 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
195
196 let key = derive_key(passphrase, &salt);
197 let cipher = Aes256Gcm::new(&key);
198
199 let tag = cipher
200 .encrypt_in_place_detached(&nonce, b"", &mut plaintext)
201 .map_err(|_| VaultError::EncryptionFailed)?;
202
203 drop(cipher);
205
206 let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len());
207 out.write_all(&MAGIC)?;
208 out.write_all(&[VERSION])?;
209 out.write_all(&salt)?;
210 out.write_all(nonce.as_slice())?;
211 out.write_all(tag.as_slice())?;
212 out.write_all(&plaintext)?;
213
214 Ok(out)
215 }
216
217 pub fn decrypt(data: &[u8], passphrase: &str) -> Result<Self, VaultError> {
222 if data.len() < HEADER_LEN {
223 return Err(VaultError::TooSmall);
224 }
225 if data[0..4] != MAGIC {
226 return Err(VaultError::BadMagic);
227 }
228 if data[4] != VERSION {
229 return Err(VaultError::UnsupportedVersion(data[4]));
230 }
231
232 let salt = &data[5..5 + SALT_LEN];
233 let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
234 let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
235 let ciphertext = &data[HEADER_LEN..];
236
237 let key = derive_key(passphrase, salt);
238 let cipher = Aes256Gcm::new(&key);
239 let nonce = Nonce::from_slice(nonce_bytes);
240 let tag = Tag::from_slice(tag_bytes);
241
242 let mut buf = ciphertext.to_vec();
243 cipher
244 .decrypt_in_place_detached(nonce, b"", &mut buf, tag)
245 .map_err(|_| VaultError::DecryptionFailed)?;
246
247 let entries = deserialize(&buf);
248
249 buf.zeroize();
251
252 Ok(Self { entries })
253 }
254
255 pub fn to_shell_exports(&self) -> String {
261 let mut out = String::new();
262 for (key, value) in &self.entries {
263 let escaped = value.replace('\'', "'\\''");
264 out.push_str(&format!("export {key}='{escaped}'\n"));
265 }
266 out
267 }
268
269 pub fn to_json(&self) -> String {
271 let mut out = String::from("{\n");
272 let len = self.entries.len();
273 for (i, (key, value)) in self.entries.iter().enumerate() {
274 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
275 out.push_str(&format!(" \"{key}\": \"{escaped}\""));
276 if i + 1 < len {
277 out.push(',');
278 }
279 out.push('\n');
280 }
281 out.push_str("}\n");
282 out
283 }
284}
285
286impl Default for Vault {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292impl Drop for Vault {
293 fn drop(&mut self) {
294 for value in self.entries.values_mut() {
296 unsafe {
297 let bytes = value.as_bytes_mut();
298 bytes.zeroize();
299 }
300 }
301 }
302}
303
304pub fn is_valid_key(key: &str) -> bool {
308 !key.is_empty()
309 && key.len() <= MAX_KEY_LEN
310 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
311}
312
313pub fn parse_env_lines(input: &str) -> Vec<(String, String)> {
317 let mut pairs = Vec::new();
318 for line in input.lines() {
319 let trimmed = line.trim();
320 if trimmed.is_empty() || trimmed.starts_with('#') {
321 continue;
322 }
323 let kv = trimmed.strip_prefix("export ").unwrap_or(trimmed);
324 if let Some((key, value)) = kv.split_once('=') {
325 let key = key.trim();
326 let value = value
327 .trim()
328 .trim_start_matches(|c| c == '"' || c == '\'')
329 .trim_end_matches(|c| c == '"' || c == '\'');
330 if is_valid_key(key) && !value.is_empty() {
331 pairs.push((key.to_string(), value.to_string()));
332 }
333 }
334 }
335 pairs
336}
337
338fn serialize(entries: &BTreeMap<String, String>) -> Vec<u8> {
341 let mut buf = Vec::new();
342 for (key, value) in entries {
343 let klen = key.len();
344 buf.push((klen >> 8) as u8);
345 buf.push((klen & 0xFF) as u8);
346 buf.extend_from_slice(key.as_bytes());
347 let vlen = value.len();
348 buf.push((vlen >> 24) as u8);
349 buf.push(((vlen >> 16) & 0xFF) as u8);
350 buf.push(((vlen >> 8) & 0xFF) as u8);
351 buf.push((vlen & 0xFF) as u8);
352 buf.extend_from_slice(value.as_bytes());
353 }
354 buf.extend_from_slice(&[0x00, 0x00]);
355 buf
356}
357
358fn deserialize(data: &[u8]) -> BTreeMap<String, String> {
359 let mut entries = BTreeMap::new();
360 let mut pos = 0;
361 while pos + 2 <= data.len() {
362 let klen = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
363 pos += 2;
364 if klen == 0 {
365 break;
366 }
367 if klen > MAX_KEY_LEN || pos + klen > data.len() {
368 break;
369 }
370 let key = String::from_utf8_lossy(&data[pos..pos + klen]).to_string();
371 pos += klen;
372 if pos + 4 > data.len() {
373 break;
374 }
375 let vlen = ((data[pos] as usize) << 24)
376 | ((data[pos + 1] as usize) << 16)
377 | ((data[pos + 2] as usize) << 8)
378 | (data[pos + 3] as usize);
379 pos += 4;
380 if vlen > MAX_VALUE_LEN || pos + vlen > data.len() {
381 break;
382 }
383 let value = String::from_utf8_lossy(&data[pos..pos + vlen]).to_string();
384 pos += vlen;
385 entries.insert(key, value);
386 }
387 entries
388}
389
390fn derive_key(passphrase: &str, salt: &[u8]) -> Key<Aes256Gcm> {
393 let key =
394 pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(passphrase.as_bytes(), salt, ITERATIONS);
395 *Key::<Aes256Gcm>::from_slice(&key)
396}
397
398#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn round_trip() {
406 let mut vault = Vault::new();
407 vault.set("API_KEY", "sk-secret-123");
408 vault.set("DB_URL", "postgres://localhost/mydb");
409
410 let encrypted = vault.encrypt("test-pass").unwrap();
411 let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
412
413 assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
414 assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
415 assert_eq!(decrypted.len(), 2);
416 }
417
418 #[test]
419 fn wrong_passphrase() {
420 let vault = Vault::new();
421 let encrypted = vault.encrypt("correct").unwrap();
422 assert!(matches!(
423 Vault::decrypt(&encrypted, "wrong"),
424 Err(VaultError::DecryptionFailed)
425 ));
426 }
427
428 #[test]
429 fn tamper_detection() {
430 let mut vault = Vault::new();
431 vault.set("KEY", "value");
432 let mut encrypted = vault.encrypt("pass").unwrap();
433 if let Some(last) = encrypted.last_mut() {
435 *last ^= 0xFF;
436 }
437 assert!(Vault::decrypt(&encrypted, "pass").is_err());
438 }
439
440 #[test]
441 fn fresh_nonce_per_encrypt() {
442 let vault = Vault::new();
443 let a = vault.encrypt("pass").unwrap();
444 let b = vault.encrypt("pass").unwrap();
445 assert_ne!(a, b);
447 }
448
449 #[test]
450 fn shell_escaping() {
451 let mut vault = Vault::new();
452 vault.set("KEY", "it's a \"test\"");
453 let exports = vault.to_shell_exports();
454 assert!(exports.contains("'it'\\''s a \"test\"'"));
455 }
456
457 #[test]
458 fn valid_keys() {
459 assert!(is_valid_key("API_KEY"));
460 assert!(is_valid_key("key123"));
461 assert!(!is_valid_key(""));
462 assert!(!is_valid_key("has space"));
463 assert!(!is_valid_key("has-dash"));
464 }
465
466 #[test]
467 fn parse_env() {
468 let input = r#"
469export API_KEY="sk-123"
470DB_URL=postgres://localhost
471# comment
472export EMPTY=
473
474BARE=value
475"#;
476 let pairs = parse_env_lines(input);
477 assert_eq!(pairs.len(), 3);
478 assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
480 assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
481 assert_eq!(pairs[2], ("BARE".into(), "value".into()));
482 }
483}