Skip to main content

secrets_vault/
lib.rs

1//! # secrets-vault
2//!
3//! AES-256-GCM encrypted key-value vault with PBKDF2-SHA256 key derivation.
4//! Binary-compatible with the [Zig version](https://github.com/quantum-encoding/quantum-zig-forge).
5//!
6//! ## Quick Start
7//!
8//! ```rust,no_run
9//! use secrets_vault::Vault;
10//!
11//! // Create or load a vault
12//! let mut vault = Vault::new();
13//! vault.set("API_KEY", "sk-secret-123");
14//! vault.set("DB_URL", "postgres://localhost/mydb");
15//!
16//! // Encrypt and save
17//! let bytes = vault.encrypt("my-passphrase")?;
18//! std::fs::write("vault.qvlt", &bytes)?;
19//!
20//! // Load and decrypt
21//! let data = std::fs::read("vault.qvlt")?;
22//! let vault = Vault::decrypt(&data, "my-passphrase")?;
23//! assert_eq!(vault.get("API_KEY"), Some("sk-secret-123"));
24//! # Ok::<(), secrets_vault::VaultError>(())
25//! ```
26//!
27//! ## Vault File Format (QVLT)
28//!
29//! ```text
30//! [4 bytes]  Magic:     "QVLT"
31//! [1 byte]   Version:   0x01
32//! [16 bytes] PBKDF2 salt (random per save)
33//! [12 bytes] AES-GCM nonce (random per save)
34//! [16 bytes] AES-GCM authentication tag
35//! [N bytes]  Ciphertext (encrypted key-value pairs)
36//! ```
37//!
38//! ## Security
39//!
40//! - AES-256-GCM authenticated encryption (NIST)
41//! - PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2023)
42//! - Fresh random salt + nonce on every encrypt
43//! - Plaintext zeroed after use
44//! - Tamper detection via GCM authentication tag
45
46use 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
53// ── Constants ──
54
55const 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; // 49
61
62/// PBKDF2 iteration count (OWASP 2023 recommendation for SHA-256).
63pub const ITERATIONS: u32 = 600_000;
64
65/// Maximum key name length in bytes.
66pub const MAX_KEY_LEN: usize = 256;
67
68/// Maximum value length in bytes.
69pub const MAX_VALUE_LEN: usize = 65536;
70
71// ── Errors ──
72
73/// Errors that can occur during vault operations.
74#[derive(Debug)]
75pub enum VaultError {
76    /// Vault file is too small to contain a valid header.
77    TooSmall,
78    /// Magic bytes don't match "QVLT".
79    BadMagic,
80    /// Vault version is not supported.
81    UnsupportedVersion(u8),
82    /// AES-GCM decryption failed — wrong passphrase or tampered data.
83    DecryptionFailed,
84    /// AES-GCM encryption failed.
85    EncryptionFailed,
86    /// Vault data is malformed.
87    MalformedData,
88    /// I/O error.
89    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// ── Vault ──
115
116/// An in-memory key-value store that can be encrypted to/from the QVLT format.
117///
118/// Keys are stored sorted (BTreeMap) for deterministic output.
119/// Values are zeroed from memory when the vault is dropped.
120#[derive(Debug, Clone)]
121pub struct Vault {
122    entries: BTreeMap<String, String>,
123}
124
125impl Vault {
126    /// Create an empty vault.
127    pub fn new() -> Self {
128        Self {
129            entries: BTreeMap::new(),
130        }
131    }
132
133    /// Create a vault from an existing map.
134    pub fn from_map(entries: BTreeMap<String, String>) -> Self {
135        Self { entries }
136    }
137
138    /// Get a value by key.
139    pub fn get(&self, key: &str) -> Option<&str> {
140        self.entries.get(key).map(|s| s.as_str())
141    }
142
143    /// Set a key-value pair. Returns the previous value if the key existed.
144    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    /// Remove a key. Returns the value if it existed.
149    pub fn delete(&mut self, key: &str) -> Option<String> {
150        self.entries.remove(key)
151    }
152
153    /// List all key names (sorted).
154    pub fn keys(&self) -> impl Iterator<Item = &str> {
155        self.entries.keys().map(|s| s.as_str())
156    }
157
158    /// Iterate over all key-value pairs (sorted by key).
159    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    /// Number of entries.
164    pub fn len(&self) -> usize {
165        self.entries.len()
166    }
167
168    /// Whether the vault is empty.
169    pub fn is_empty(&self) -> bool {
170        self.entries.is_empty()
171    }
172
173    /// Get a mutable reference to the underlying map.
174    pub fn entries_mut(&mut self) -> &mut BTreeMap<String, String> {
175        &mut self.entries
176    }
177
178    /// Clone the underlying map. (Cannot move due to Drop impl that zeroes memory.)
179    pub fn to_map(&self) -> BTreeMap<String, String> {
180        self.entries.clone()
181    }
182
183    // ── Encryption / Decryption ──
184
185    /// Encrypt the vault into QVLT binary format.
186    ///
187    /// Uses a fresh random salt and nonce each time, so calling this twice
188    /// with the same data produces different ciphertext.
189    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        // Zero the derived key (it's on the stack, but let's be explicit)
204        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    /// Decrypt a QVLT binary blob into a vault.
218    ///
219    /// Returns `VaultError::DecryptionFailed` if the passphrase is wrong
220    /// or the data has been tampered with.
221    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        // Zero plaintext
250        buf.zeroize();
251
252        Ok(Self { entries })
253    }
254
255    // ── Shell output helpers ──
256
257    /// Format all entries as `export KEY='VALUE'` lines for shell eval.
258    ///
259    /// Single quotes in values are escaped as `'\''`.
260    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    /// Format all entries as a JSON object.
270    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        // Zero all values in memory
295        for value in self.entries.values_mut() {
296            unsafe {
297                let bytes = value.as_bytes_mut();
298                bytes.zeroize();
299            }
300        }
301    }
302}
303
304// ── Key validation ──
305
306/// Check if a key name is valid (alphanumeric + underscore, non-empty, ≤256 bytes).
307pub 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
313/// Parse KEY=VALUE lines (with optional `export` prefix and quote stripping).
314///
315/// Useful for importing from `.env` files or shell config exports.
316pub 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
338// ── Internal: Binary serialization (QVLT format) ──
339
340fn 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
390// ── Internal: Crypto ──
391
392fn 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// ── Tests ──
399
400#[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        // Flip a byte in the ciphertext
434        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        // Same data, different ciphertext (different salt + nonce)
446        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        // parse_env_lines returns in file order, not sorted
479        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}