Skip to main content

secrets_vault/
lib.rs

1//! # secrets-vault
2//!
3//! AES-256-GCM encrypted key-value vault. **QVLT v2** encrypts every entry under
4//! its own HKDF-derived key, so reading one secret decrypts exactly one record —
5//! never the whole vault. The legacy v1 single-blob format is still readable
6//! (migration only). Full format spec: `QVLT2_SPEC.md`.
7//!
8//! ## Quick Start
9//!
10//! ```rust,no_run
11//! use secrets_vault::Vault;
12//!
13//! // Create or load a vault
14//! let mut vault = Vault::new();
15//! vault.set("API_KEY", "sk-secret-123");
16//! vault.set("DB_URL", "postgres://localhost/mydb");
17//!
18//! // Encrypt and save
19//! let bytes = vault.encrypt("my-passphrase")?;
20//! std::fs::write("vault.qvlt", &bytes)?;
21//!
22//! // Load and decrypt
23//! let data = std::fs::read("vault.qvlt")?;
24//! let vault = Vault::decrypt(&data, "my-passphrase")?;
25//! assert_eq!(vault.get("API_KEY"), Some("sk-secret-123"));
26//! # Ok::<(), secrets_vault::VaultError>(())
27//! ```
28//!
29//! ## Vault File Format (QVLT v2 — see QVLT2_SPEC.md §4)
30//!
31//! ```text
32//! [4]  Magic "QVLT"   [1] Version 0x02   [1] KDF id   [2] Flags (0)
33//! [16] Vault salt (stable across saves)  [4] u32 entry count
34//! per entry (sorted by name): [1] scheme  [2] name len  [N] name
35//!                             [12] nonce  [16] tag  [4] ct len  [C] ct
36//! [32] Manifest MAC = HMAC-SHA256(mac_key, file[..len-32])
37//! ```
38//!
39//! v1 (version 0x01) is a single blob: salt + nonce + tag + one ciphertext of
40//! all entries. Kept read-only for migration.
41//!
42//! ## Security
43//!
44//! - AES-256-GCM authenticated encryption per entry; AAD binds version‖scheme‖name
45//! - PBKDF2-HMAC-SHA256 @ 600,000 iterations once per open → HKDF-SHA256 per entry
46//! - Values padded to 32-byte buckets (true length inside the authenticated plaintext)
47//! - Whole-file HMAC-SHA256 manifest (constant-time verify) — deletion/reorder/rollback-within-file detection
48//! - Plaintext and key material zeroized after use
49
50use std::collections::BTreeMap;
51use std::io::Write;
52
53use aes_gcm::aead::{AeadCore, AeadInPlace, KeyInit, OsRng, rand_core::RngCore};
54use aes_gcm::{Aes256Gcm, Key, Nonce, Tag};
55use zeroize::{Zeroize, Zeroizing};
56
57// ── Constants ──
58
59const MAGIC: [u8; 4] = *b"QVLT";
60const VERSION: u8 = 0x01;
61/// Vault-level KDF salt length (v1 and v2).
62pub const SALT_LEN: usize = 16;
63const NONCE_LEN: usize = 12;
64const TAG_LEN: usize = 16;
65const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN + TAG_LEN; // 49
66
67/// PBKDF2 iteration count (OWASP 2023 recommendation for SHA-256).
68pub const ITERATIONS: u32 = 600_000;
69
70/// Maximum key name length in bytes.
71pub const MAX_KEY_LEN: usize = 256;
72
73/// Maximum value length in bytes.
74pub const MAX_VALUE_LEN: usize = 65536;
75
76// ── Errors ──
77
78/// Errors that can occur during vault operations.
79#[derive(Debug)]
80pub enum VaultError {
81    /// Vault file is too small to contain a valid header.
82    TooSmall,
83    /// Magic bytes don't match "QVLT".
84    BadMagic,
85    /// Vault version is not supported.
86    UnsupportedVersion(u8),
87    /// AES-GCM decryption failed — wrong passphrase or tampered data.
88    DecryptionFailed,
89    /// AES-GCM encryption failed.
90    EncryptionFailed,
91    /// Vault data is malformed.
92    MalformedData,
93    /// v2: header names a KDF this build doesn't implement.
94    UnsupportedKdf(u8),
95    /// v2: unknown header flag bits set — fail closed (spec §4).
96    UnsupportedFlags(u16),
97    /// v2: entry uses a key scheme this build doesn't implement (spec §4.6).
98    UnknownScheme(u8),
99    /// v2: no entry with the requested name.
100    NotFound,
101    /// I/O error.
102    Io(std::io::Error),
103}
104
105impl std::fmt::Display for VaultError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        match self {
108            Self::TooSmall => write!(f, "vault file too small"),
109            Self::BadMagic => write!(f, "invalid vault file (bad magic)"),
110            Self::UnsupportedVersion(v) => write!(f, "unsupported vault version: {v}"),
111            Self::DecryptionFailed => write!(f, "decryption failed (wrong passphrase?)"),
112            Self::EncryptionFailed => write!(f, "encryption failed"),
113            Self::MalformedData => write!(f, "malformed vault data"),
114            Self::UnsupportedKdf(k) => write!(f, "unsupported KDF id: {k}"),
115            Self::UnsupportedFlags(fl) => write!(f, "unsupported vault flags: {fl:#06x}"),
116            Self::UnknownScheme(s) => write!(f, "unknown entry key scheme: {s}"),
117            Self::NotFound => write!(f, "key not found"),
118            Self::Io(e) => write!(f, "I/O error: {e}"),
119        }
120    }
121}
122
123impl std::error::Error for VaultError {}
124
125impl From<std::io::Error> for VaultError {
126    fn from(e: std::io::Error) -> Self {
127        Self::Io(e)
128    }
129}
130
131// ── Vault ──
132
133/// An in-memory key-value store that can be encrypted to/from the QVLT format.
134///
135/// Keys are stored sorted (BTreeMap) for deterministic output.
136/// Values are zeroed from memory when the vault is dropped.
137#[derive(Debug, Clone)]
138pub struct Vault {
139    entries: BTreeMap<String, String>,
140}
141
142impl Vault {
143    /// Create an empty vault.
144    pub fn new() -> Self {
145        Self {
146            entries: BTreeMap::new(),
147        }
148    }
149
150    /// Create a vault from an existing map.
151    pub fn from_map(entries: BTreeMap<String, String>) -> Self {
152        Self { entries }
153    }
154
155    /// Get a value by key.
156    pub fn get(&self, key: &str) -> Option<&str> {
157        self.entries.get(key).map(|s| s.as_str())
158    }
159
160    /// Set a key-value pair. Returns the previous value if the key existed.
161    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
162        self.entries.insert(key.into(), value.into())
163    }
164
165    /// Remove a key. Returns the value if it existed.
166    pub fn delete(&mut self, key: &str) -> Option<String> {
167        self.entries.remove(key)
168    }
169
170    /// List all key names (sorted).
171    pub fn keys(&self) -> impl Iterator<Item = &str> {
172        self.entries.keys().map(|s| s.as_str())
173    }
174
175    /// Iterate over all key-value pairs (sorted by key).
176    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
177        self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
178    }
179
180    /// Number of entries.
181    pub fn len(&self) -> usize {
182        self.entries.len()
183    }
184
185    /// Whether the vault is empty.
186    pub fn is_empty(&self) -> bool {
187        self.entries.is_empty()
188    }
189
190    /// Get a mutable reference to the underlying map.
191    pub fn entries_mut(&mut self) -> &mut BTreeMap<String, String> {
192        &mut self.entries
193    }
194
195    /// Clone the underlying map. (Cannot move due to Drop impl that zeroes memory.)
196    pub fn to_map(&self) -> BTreeMap<String, String> {
197        self.entries.clone()
198    }
199
200    // ── Encryption / Decryption ──
201
202    /// Encrypt the vault into QVLT binary format.
203    ///
204    /// Uses a fresh random salt and nonce each time, so calling this twice
205    /// with the same data produces different ciphertext.
206    pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>, VaultError> {
207        let mut plaintext = serialize(&self.entries);
208
209        let mut salt = [0u8; SALT_LEN];
210        OsRng.fill_bytes(&mut salt);
211        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
212
213        let key = derive_key(passphrase, &salt);
214        let cipher = Aes256Gcm::new(&key);
215
216        let tag = cipher
217            .encrypt_in_place_detached(&nonce, b"", &mut plaintext)
218            .map_err(|_| VaultError::EncryptionFailed)?;
219
220        // Zero the derived key (it's on the stack, but let's be explicit)
221        drop(cipher);
222
223        let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len());
224        out.write_all(&MAGIC)?;
225        out.write_all(&[VERSION])?;
226        out.write_all(&salt)?;
227        out.write_all(nonce.as_slice())?;
228        out.write_all(tag.as_slice())?;
229        out.write_all(&plaintext)?;
230
231        Ok(out)
232    }
233
234    /// Decrypt a QVLT binary blob into a vault.
235    ///
236    /// Returns `VaultError::DecryptionFailed` if the passphrase is wrong
237    /// or the data has been tampered with.
238    pub fn decrypt(data: &[u8], passphrase: &str) -> Result<Self, VaultError> {
239        if data.len() < HEADER_LEN {
240            return Err(VaultError::TooSmall);
241        }
242        if data[0..4] != MAGIC {
243            return Err(VaultError::BadMagic);
244        }
245        if data[4] != VERSION {
246            return Err(VaultError::UnsupportedVersion(data[4]));
247        }
248
249        let salt = &data[5..5 + SALT_LEN];
250        let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
251        let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
252        let ciphertext = &data[HEADER_LEN..];
253
254        let key = derive_key(passphrase, salt);
255        let cipher = Aes256Gcm::new(&key);
256        let nonce = Nonce::from_slice(nonce_bytes);
257        let tag = Tag::from_slice(tag_bytes);
258
259        let mut buf = ciphertext.to_vec();
260        cipher
261            .decrypt_in_place_detached(nonce, b"", &mut buf, tag)
262            .map_err(|_| VaultError::DecryptionFailed)?;
263
264        let entries = deserialize(&buf);
265
266        // Zero plaintext
267        buf.zeroize();
268
269        Ok(Self { entries })
270    }
271
272    // ── Shell output helpers ──
273
274    /// Format all entries as `export KEY='VALUE'` lines for shell eval.
275    ///
276    /// Single quotes in values are escaped as `'\''`.
277    pub fn to_shell_exports(&self) -> String {
278        let mut out = String::new();
279        for (key, value) in &self.entries {
280            let escaped = value.replace('\'', "'\\''");
281            out.push_str(&format!("export {key}='{escaped}'\n"));
282        }
283        out
284    }
285
286    /// Format all entries as a JSON object.
287    pub fn to_json(&self) -> String {
288        let mut out = String::from("{\n");
289        let len = self.entries.len();
290        for (i, (key, value)) in self.entries.iter().enumerate() {
291            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
292            out.push_str(&format!("  \"{key}\": \"{escaped}\""));
293            if i + 1 < len {
294                out.push(',');
295            }
296            out.push('\n');
297        }
298        out.push_str("}\n");
299        out
300    }
301}
302
303impl Default for Vault {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309impl Drop for Vault {
310    fn drop(&mut self) {
311        // Zero all values in memory
312        for value in self.entries.values_mut() {
313            unsafe {
314                let bytes = value.as_bytes_mut();
315                bytes.zeroize();
316            }
317        }
318    }
319}
320
321// ── Key validation ──
322
323/// Check if a key name is valid: non-empty, ≤256 bytes, and `[A-Za-z0-9_-]` only.
324/// This is exactly Google Secret Manager's allowed secret-ID charset (hyphens are
325/// valid there; dots are NOT), so a key that validates here is storable in every
326/// backend — keychain account, HashMap key, and GSM secret ID alike. Hyphens matter
327/// in practice: many real-world secret names are kebab-case (e.g. `prod-db-password`).
328pub fn is_valid_key(key: &str) -> bool {
329    !key.is_empty()
330        && key.len() <= MAX_KEY_LEN
331        && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
332}
333
334/// Check a project namespace is safe to use as a vault-key prefix: non-empty,
335/// ≤256 bytes, and `[A-Za-z0-9_.-]` only — crucially NO slash, so `project/KEY`
336/// has exactly one separator and can't be traversal/injection-abused.
337pub fn is_valid_project(name: &str) -> bool {
338    !name.is_empty()
339        && name.len() <= MAX_KEY_LEN
340        && name
341            .chars()
342            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
343}
344
345/// Parse KEY=VALUE lines (with optional `export` prefix and quote stripping).
346///
347/// Useful for importing from `.env` files or shell config exports.
348pub fn parse_env_lines(input: &str) -> Vec<(String, String)> {
349    let mut pairs = Vec::new();
350    for line in input.lines() {
351        let trimmed = line.trim();
352        if trimmed.is_empty() || trimmed.starts_with('#') {
353            continue;
354        }
355        let kv = trimmed.strip_prefix("export ").unwrap_or(trimmed);
356        if let Some((key, value)) = kv.split_once('=') {
357            let key = key.trim();
358            let value = value
359                .trim()
360                .trim_start_matches(|c| c == '"' || c == '\'')
361                .trim_end_matches(|c| c == '"' || c == '\'');
362            if is_valid_key(key) && !value.is_empty() {
363                pairs.push((key.to_string(), value.to_string()));
364            }
365        }
366    }
367    pairs
368}
369
370/// Generate `n` cryptographically secure random bytes from the OS CSPRNG.
371/// Used by `secrets gen` so a fresh credential never has to be printed.
372pub fn random_bytes(n: usize) -> Vec<u8> {
373    let mut buf = vec![0u8; n];
374    OsRng.fill_bytes(&mut buf);
375    buf
376}
377
378/// Fresh random vault salt (v2: generated at vault creation / `rekey` only —
379/// stable across ordinary saves so splicing needs no re-encryption).
380pub fn random_salt() -> [u8; SALT_LEN] {
381    let mut salt = [0u8; SALT_LEN];
382    OsRng.fill_bytes(&mut salt);
383    salt
384}
385
386/// Encrypt arbitrary bytes into the same QVLT container the vault uses (AES-256-GCM,
387/// PBKDF2-SHA256 @ 600k, fresh salt+nonce). Lets other on-disk artifacts (e.g. the
388/// scoped registry) reuse the audited crypto core without touching `Vault`.
389pub fn encrypt_blob(plaintext: &[u8], passphrase: &str) -> Result<Vec<u8>, VaultError> {
390    let mut buf = plaintext.to_vec();
391    let mut salt = [0u8; SALT_LEN];
392    OsRng.fill_bytes(&mut salt);
393    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
394    let key = derive_key(passphrase, &salt);
395    let cipher = Aes256Gcm::new(&key);
396    let tag = cipher
397        .encrypt_in_place_detached(&nonce, b"", &mut buf)
398        .map_err(|_| VaultError::EncryptionFailed)?;
399
400    let mut out = Vec::with_capacity(HEADER_LEN + buf.len());
401    out.write_all(&MAGIC)?;
402    out.write_all(&[VERSION])?;
403    out.write_all(&salt)?;
404    out.write_all(nonce.as_slice())?;
405    out.write_all(tag.as_slice())?;
406    out.write_all(&buf)?;
407    buf.zeroize();
408    Ok(out)
409}
410
411/// Decrypt a QVLT container produced by [`encrypt_blob`]. Returns the plaintext
412/// bytes (caller zeroizes after use).
413pub fn decrypt_blob(data: &[u8], passphrase: &str) -> Result<Vec<u8>, VaultError> {
414    if data.len() < HEADER_LEN {
415        return Err(VaultError::TooSmall);
416    }
417    if data[0..4] != MAGIC {
418        return Err(VaultError::BadMagic);
419    }
420    if data[4] != VERSION {
421        return Err(VaultError::UnsupportedVersion(data[4]));
422    }
423    let salt = &data[5..5 + SALT_LEN];
424    let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
425    let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
426    let ciphertext = &data[HEADER_LEN..];
427
428    let key = derive_key(passphrase, salt);
429    let cipher = Aes256Gcm::new(&key);
430    let nonce = Nonce::from_slice(nonce_bytes);
431    let tag = Tag::from_slice(tag_bytes);
432
433    let mut buf = ciphertext.to_vec();
434    cipher
435        .decrypt_in_place_detached(nonce, b"", &mut buf, tag)
436        .map_err(|_| VaultError::DecryptionFailed)?;
437    Ok(buf)
438}
439
440// ── Internal: Binary serialization (QVLT format) ──
441
442fn serialize(entries: &BTreeMap<String, String>) -> Vec<u8> {
443    let mut buf = Vec::new();
444    for (key, value) in entries {
445        let klen = key.len();
446        buf.push((klen >> 8) as u8);
447        buf.push((klen & 0xFF) as u8);
448        buf.extend_from_slice(key.as_bytes());
449        let vlen = value.len();
450        buf.push((vlen >> 24) as u8);
451        buf.push(((vlen >> 16) & 0xFF) as u8);
452        buf.push(((vlen >> 8) & 0xFF) as u8);
453        buf.push((vlen & 0xFF) as u8);
454        buf.extend_from_slice(value.as_bytes());
455    }
456    buf.extend_from_slice(&[0x00, 0x00]);
457    buf
458}
459
460fn deserialize(data: &[u8]) -> BTreeMap<String, String> {
461    let mut entries = BTreeMap::new();
462    let mut pos = 0;
463    while pos + 2 <= data.len() {
464        let klen = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
465        pos += 2;
466        if klen == 0 {
467            break;
468        }
469        if klen > MAX_KEY_LEN || pos + klen > data.len() {
470            break;
471        }
472        let key = String::from_utf8_lossy(&data[pos..pos + klen]).to_string();
473        pos += klen;
474        if pos + 4 > data.len() {
475            break;
476        }
477        let vlen = ((data[pos] as usize) << 24)
478            | ((data[pos + 1] as usize) << 16)
479            | ((data[pos + 2] as usize) << 8)
480            | (data[pos + 3] as usize);
481        pos += 4;
482        if vlen > MAX_VALUE_LEN || pos + vlen > data.len() {
483            break;
484        }
485        let value = String::from_utf8_lossy(&data[pos..pos + vlen]).to_string();
486        pos += vlen;
487        entries.insert(key, value);
488    }
489    entries
490}
491
492// ── Internal: Crypto ──
493
494fn derive_key(passphrase: &str, salt: &[u8]) -> Key<Aes256Gcm> {
495    let key =
496        pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(passphrase.as_bytes(), salt, ITERATIONS);
497    *Key::<Aes256Gcm>::from_slice(&key)
498}
499
500// ═══════════════════════════════════════════════════════════════════════════
501// QVLT v2 — per-entry encryption (QVLT2_SPEC.md)
502// ═══════════════════════════════════════════════════════════════════════════
503
504use hmac::Mac;
505
506/// Disambiguated HMAC constructor (`aes_gcm::KeyInit` and `hmac::Mac` both
507/// define `new_from_slice` and both are in scope).
508fn new_hmac(key: &[u8]) -> HmacSha256 {
509    <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length")
510}
511
512/// v2 version byte.
513pub const V2_VERSION: u8 = 0x02;
514/// KDF id: PBKDF2-HMAC-SHA256 @ [`ITERATIONS`].
515pub const KDF_PBKDF2: u8 = 0x01;
516/// Entry key scheme: HKDF from the master secret.
517pub const SCHEME_HKDF: u8 = 0x01;
518/// v2 header: magic(4) + version(1) + kdf(1) + flags(2) + salt(16) + count(4).
519pub const V2_HEADER_LEN: usize = 4 + 1 + 1 + 2 + SALT_LEN + 4; // 28
520/// Trailing manifest MAC length (HMAC-SHA256).
521pub const MAC_LEN: usize = 32;
522/// Value padding bucket (spec §4.2): plaintext bodies are 4-byte true-length
523/// prefix + value + zero pad, rounded up to a multiple of this.
524pub const PAD_BLOCK: usize = 32;
525/// Max storage-name length: project(256) + '/'(1) + key(256).
526pub const MAX_NAME_LEN: usize = 513;
527/// Largest legal padded body: 4-byte length prefix + MAX_VALUE_LEN, bucketed.
528const MAX_PADDED: usize = (4 + MAX_VALUE_LEN).div_ceil(PAD_BLOCK) * PAD_BLOCK;
529
530type HmacSha256 = hmac::Hmac<sha2::Sha256>;
531
532/// A storage name is either `KEY` or `project/KEY` (spec §4.1) — at most one `/`.
533pub fn is_valid_storage_name(name: &str) -> bool {
534    match name.split_once('/') {
535        Some((project, key)) => is_valid_project(project) && is_valid_key(key),
536        None => is_valid_key(name),
537    }
538}
539
540/// The vault-level KDF output. Derive ONCE per open (the expensive step), then
541/// zeroize the passphrase — every other key HKDF-derives from this (spec §4.3).
542/// Carries the salt it was derived under so entry/mac/registry keys need no
543/// extra context.
544pub struct MasterSecret {
545    secret: Zeroizing<[u8; 32]>,
546    salt: [u8; SALT_LEN],
547}
548
549impl MasterSecret {
550    /// PBKDF2-HMAC-SHA256 @ 600k over the passphrase. The caller should drop
551    /// (zeroize) the passphrase immediately after this returns.
552    pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Self {
553        let secret = Zeroizing::new(pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
554            passphrase.as_bytes(),
555            salt,
556            ITERATIONS,
557        ));
558        Self { secret, salt: *salt }
559    }
560
561    pub fn salt(&self) -> &[u8; SALT_LEN] {
562        &self.salt
563    }
564
565    fn hkdf(&self, info: &[u8]) -> Zeroizing<[u8; 32]> {
566        let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&self.salt), self.secret.as_ref());
567        let mut okm = Zeroizing::new([0u8; 32]);
568        hk.expand(info, okm.as_mut())
569            .expect("32 bytes is a valid HKDF-SHA256 output length");
570        okm
571    }
572
573    /// Per-entry key. Info length-prefixes the name so entry infos are
574    /// prefix-free among themselves and against the fixed labels (spec §4.3).
575    fn entry_key(&self, name: &str) -> Zeroizing<[u8; 32]> {
576        let mut info = Vec::with_capacity(14 + name.len());
577        info.extend_from_slice(b"qvlt2:entry:");
578        info.extend_from_slice(&(name.len() as u16).to_be_bytes());
579        info.extend_from_slice(name.as_bytes());
580        self.hkdf(&info)
581    }
582
583    fn mac_key(&self) -> Zeroizing<[u8; 32]> {
584        self.hkdf(b"qvlt2:manifest")
585    }
586
587    /// Key for the raw-key registry container (spec §6.2).
588    pub fn registry_key(&self) -> Zeroizing<[u8; 32]> {
589        self.hkdf(b"qvlt2:registry")
590    }
591}
592
593/// True if the bytes look like a QVLT v2 file (magic + version only — full
594/// validation happens in [`VaultReader::open`]).
595pub fn is_v2(data: &[u8]) -> bool {
596    data.len() >= V2_HEADER_LEN && data[0..4] == MAGIC && data[4] == V2_VERSION
597}
598
599/// True if the bytes look like a legacy v1 file.
600pub fn is_v1(data: &[u8]) -> bool {
601    data.len() >= HEADER_LEN && data[0..4] == MAGIC && data[4] == VERSION
602}
603
604/// Parse a v2 header far enough to return the vault salt — needed BEFORE key
605/// derivation (chicken/egg: the master secret derives from this salt). Also
606/// enforces the fail-closed prefix checks: magic, version, KDF id, zero flags.
607pub fn v2_salt(data: &[u8]) -> Result<[u8; SALT_LEN], VaultError> {
608    if data.len() < V2_HEADER_LEN + MAC_LEN {
609        return Err(VaultError::TooSmall);
610    }
611    if data[0..4] != MAGIC {
612        return Err(VaultError::BadMagic);
613    }
614    if data[4] != V2_VERSION {
615        return Err(VaultError::UnsupportedVersion(data[4]));
616    }
617    if data[5] != KDF_PBKDF2 {
618        return Err(VaultError::UnsupportedKdf(data[5]));
619    }
620    let flags = u16::from_be_bytes([data[6], data[7]]);
621    if flags != 0 {
622        return Err(VaultError::UnsupportedFlags(flags));
623    }
624    let mut salt = [0u8; SALT_LEN];
625    salt.copy_from_slice(&data[8..8 + SALT_LEN]);
626    Ok(salt)
627}
628
629/// One parsed (not decrypted) record: byte offsets into the file image.
630struct RecordMeta {
631    scheme: u8,
632    name: String,
633    /// Offset of the record's first byte (the scheme byte).
634    rec_off: usize,
635    /// Offset of the nonce (name end).
636    nonce_off: usize,
637    ct_len: usize,
638}
639
640impl RecordMeta {
641    fn tag_off(&self) -> usize {
642        self.nonce_off + NONCE_LEN
643    }
644    fn ct_off(&self) -> usize {
645        self.tag_off() + TAG_LEN + 4
646    }
647    fn end(&self) -> usize {
648        self.ct_off() + self.ct_len
649    }
650}
651
652/// A validated v2 vault image supporting selective decryption and no-read
653/// splicing (spec §5). Holds the raw bytes; values stay ciphertext until
654/// [`Self::decrypt_one`] is called for a specific name.
655pub struct VaultReader {
656    data: Vec<u8>,
657    salt: [u8; SALT_LEN],
658    records: Vec<RecordMeta>,
659}
660
661impl VaultReader {
662    /// Full structural validation + manifest MAC verification (spec §4,
663    /// normative order: bounds before any length-driven allocation, exact
664    /// tiling, sorted unique valid names, then MAC).
665    pub fn open(data: Vec<u8>, master: &MasterSecret) -> Result<Self, VaultError> {
666        let salt = v2_salt(&data)?;
667        if salt != master.salt {
668            // Wrong MasterSecret for this file (e.g. stale broker after rekey).
669            return Err(VaultError::DecryptionFailed);
670        }
671        let count = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as usize;
672        let body_end = data.len() - MAC_LEN;
673
674        let mut records = Vec::new();
675        let mut pos = V2_HEADER_LEN;
676        for _ in 0..count {
677            // Every length is bounds-checked against the remaining region
678            // BEFORE any slice/allocation driven by it (spec §4).
679            if pos + 3 > body_end {
680                return Err(VaultError::MalformedData);
681            }
682            let scheme = data[pos];
683            if scheme != SCHEME_HKDF {
684                // v2 readers know only scheme 0x01; unknown schemes have
685                // unknown layouts, so the whole parse fails closed (§4.6).
686                return Err(VaultError::UnknownScheme(scheme));
687            }
688            let name_len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
689            if name_len == 0 || name_len > MAX_NAME_LEN || pos + 3 + name_len + NONCE_LEN + TAG_LEN + 4 > body_end {
690                return Err(VaultError::MalformedData);
691            }
692            let name = std::str::from_utf8(&data[pos + 3..pos + 3 + name_len])
693                .map_err(|_| VaultError::MalformedData)?
694                .to_string();
695            if !is_valid_storage_name(&name) {
696                return Err(VaultError::MalformedData);
697            }
698            if let Some(prev) = records.last() {
699                let prev: &RecordMeta = prev;
700                if prev.name.as_bytes() >= name.as_bytes() {
701                    // Strict byte-lexicographic order ⇒ sorted AND unique.
702                    return Err(VaultError::MalformedData);
703                }
704            }
705            let nonce_off = pos + 3 + name_len;
706            let ct_len_off = nonce_off + NONCE_LEN + TAG_LEN;
707            let ct_len = u32::from_be_bytes([
708                data[ct_len_off],
709                data[ct_len_off + 1],
710                data[ct_len_off + 2],
711                data[ct_len_off + 3],
712            ]) as usize;
713            if ct_len == 0
714                || ct_len % PAD_BLOCK != 0
715                || ct_len > MAX_PADDED
716                || ct_len_off + 4 + ct_len > body_end
717            {
718                return Err(VaultError::MalformedData);
719            }
720            records.push(RecordMeta { scheme, name, rec_off: pos, nonce_off, ct_len });
721            pos = ct_len_off + 4 + ct_len;
722        }
723        if pos != body_end {
724            // Records must exactly tile the region between header and MAC.
725            return Err(VaultError::MalformedData);
726        }
727
728        let mac_key = master.mac_key();
729        let mut mac = new_hmac(mac_key.as_ref());
730        mac.update(&data[..body_end]);
731        // (spec §4.5: MAC input is file || external_context; context is empty in v2)
732        mac.verify_slice(&data[body_end..])
733            .map_err(|_| VaultError::DecryptionFailed)?;
734
735        Ok(Self { data, salt, records })
736    }
737
738    /// Entry names, sorted. No decryption.
739    pub fn names(&self) -> impl Iterator<Item = &str> {
740        self.records.iter().map(|r| r.name.as_str())
741    }
742
743    pub fn len(&self) -> usize {
744        self.records.len()
745    }
746
747    pub fn is_empty(&self) -> bool {
748        self.records.is_empty()
749    }
750
751    pub fn contains(&self, name: &str) -> bool {
752        self.find(name).is_some()
753    }
754
755    fn find(&self, name: &str) -> Option<&RecordMeta> {
756        self.records
757            .binary_search_by(|r| r.name.as_str().cmp(name))
758            .ok()
759            .map(|i| &self.records[i])
760    }
761
762    /// Decrypt exactly one record (G1). Every other entry stays ciphertext.
763    /// Returns the true-length value bytes.
764    pub fn decrypt_one(
765        &self,
766        master: &MasterSecret,
767        name: &str,
768    ) -> Result<Zeroizing<Vec<u8>>, VaultError> {
769        let rec = self.find(name).ok_or(VaultError::NotFound)?;
770        let key = master.entry_key(name);
771        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
772        let nonce = Nonce::from_slice(&self.data[rec.nonce_off..rec.nonce_off + NONCE_LEN]);
773        let tag = Tag::from_slice(&self.data[rec.tag_off()..rec.tag_off() + TAG_LEN]);
774        let aad = record_aad(rec.scheme, name);
775
776        let mut body =
777            Zeroizing::new(self.data[rec.ct_off()..rec.ct_off() + rec.ct_len].to_vec());
778        cipher
779            .decrypt_in_place_detached(nonce, &aad, body.as_mut_slice(), tag)
780            .map_err(|_| VaultError::DecryptionFailed)?;
781
782        let true_len =
783            u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
784        if true_len > MAX_VALUE_LEN || 4 + true_len > body.len() {
785            return Err(VaultError::MalformedData);
786        }
787        Ok(Zeroizing::new(body[4..4 + true_len].to_vec()))
788    }
789
790    /// Decrypt every entry — for the inherently whole-vault operations
791    /// (`list`, `env`, `export`, migration). Discouraged elsewhere.
792    pub fn decrypt_all(&self, master: &MasterSecret) -> Result<Vault, VaultError> {
793        let mut entries = BTreeMap::new();
794        for r in &self.records {
795            let v = self.decrypt_one(master, &r.name)?;
796            let s = String::from_utf8_lossy(&v).into_owned();
797            entries.insert(r.name.clone(), s);
798        }
799        Ok(Vault { entries })
800    }
801
802    /// Rebuild the file with `upserts` applied and `deletes` removed — WITHOUT
803    /// decrypting any untouched entry (spec §5.2): their record bytes are
804    /// re-emitted verbatim; only the manifest MAC is recomputed.
805    pub fn splice(
806        &self,
807        master: &MasterSecret,
808        upserts: &[(String, Zeroizing<Vec<u8>>)],
809        deletes: &[String],
810    ) -> Result<Vec<u8>, VaultError> {
811        enum Src<'a> {
812            Keep(&'a RecordMeta),
813            New(&'a str, &'a [u8]),
814        }
815        let mut merged: BTreeMap<&str, Src> = self
816            .records
817            .iter()
818            .map(|r| (r.name.as_str(), Src::Keep(r)))
819            .collect();
820        for name in deletes {
821            merged.remove(name.as_str());
822        }
823        for (name, value) in upserts {
824            if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
825                return Err(VaultError::MalformedData);
826            }
827            merged.insert(&name[..], Src::New(name, value));
828        }
829
830        let mut out = Vec::new();
831        write_v2_header(&mut out, &self.salt, merged.len() as u32)?;
832        for (name, src) in &merged {
833            match src {
834                Src::Keep(rec) => out.extend_from_slice(&self.data[rec.rec_off..rec.end()]),
835                Src::New(name, value) => write_record(&mut out, master, name, value)?,
836            }
837            let _ = name;
838        }
839        append_mac(&mut out, master);
840        Ok(out)
841    }
842}
843
844fn record_aad(scheme: u8, name: &str) -> Vec<u8> {
845    let mut aad = Vec::with_capacity(2 + name.len());
846    aad.push(V2_VERSION);
847    aad.push(scheme);
848    aad.extend_from_slice(name.as_bytes());
849    aad
850}
851
852fn write_v2_header(out: &mut Vec<u8>, salt: &[u8; SALT_LEN], count: u32) -> Result<(), VaultError> {
853    out.write_all(&MAGIC)?;
854    out.write_all(&[V2_VERSION, KDF_PBKDF2, 0, 0])?;
855    out.write_all(salt)?;
856    out.write_all(&count.to_be_bytes())?;
857    Ok(())
858}
859
860fn write_record(
861    out: &mut Vec<u8>,
862    master: &MasterSecret,
863    name: &str,
864    value: &[u8],
865) -> Result<(), VaultError> {
866    // Padded body: u32 true length + value + zero pad to the bucket (§4.2).
867    let padded = (4 + value.len()).div_ceil(PAD_BLOCK) * PAD_BLOCK;
868    let mut body = Zeroizing::new(vec![0u8; padded]);
869    body[..4].copy_from_slice(&(value.len() as u32).to_be_bytes());
870    body[4..4 + value.len()].copy_from_slice(value);
871
872    let key = master.entry_key(name);
873    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
874    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
875    let aad = record_aad(SCHEME_HKDF, name);
876    let tag = cipher
877        .encrypt_in_place_detached(&nonce, &aad, body.as_mut_slice())
878        .map_err(|_| VaultError::EncryptionFailed)?;
879
880    out.push(SCHEME_HKDF);
881    out.write_all(&(name.len() as u16).to_be_bytes())?;
882    out.write_all(name.as_bytes())?;
883    out.write_all(nonce.as_slice())?;
884    out.write_all(tag.as_slice())?;
885    out.write_all(&(padded as u32).to_be_bytes())?;
886    out.write_all(&body)?;
887    Ok(())
888}
889
890fn append_mac(out: &mut Vec<u8>, master: &MasterSecret) {
891    let mac_key = master.mac_key();
892    let mut mac = new_hmac(mac_key.as_ref());
893    mac.update(out);
894    out.extend_from_slice(&mac.finalize().into_bytes());
895}
896
897/// Build a fresh v2 file from scratch (empty vault creation, migration,
898/// rekey). `entries` need not be sorted; names are validated.
899pub fn v2_create(
900    master: &MasterSecret,
901    entries: &[(String, Zeroizing<Vec<u8>>)],
902) -> Result<Vec<u8>, VaultError> {
903    let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
904    for (name, value) in entries {
905        if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
906            return Err(VaultError::MalformedData);
907        }
908        sorted.insert(&name[..], value);
909    }
910    let mut out = Vec::new();
911    write_v2_header(&mut out, &master.salt, sorted.len() as u32)?;
912    for (name, value) in &sorted {
913        write_record(&mut out, master, name, value)?;
914    }
915    append_mac(&mut out, master);
916    Ok(out)
917}
918
919// ── Raw-key blob container (registry v2, spec §6.2) ──
920//
921// The registry moves off direct-passphrase PBKDF2 onto a key HKDF-derived from
922// the master secret — the expensive KDF already happened at the vault level,
923// and it lets the session broker zeroize the passphrase at startup.
924
925const RAW_MAGIC: [u8; 4] = *b"QRG2";
926const RAW_AAD: &[u8] = b"qvlt2:registry";
927
928pub fn encrypt_raw_blob(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
929    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
930    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
931    let mut buf = plaintext.to_vec();
932    let tag = cipher
933        .encrypt_in_place_detached(&nonce, RAW_AAD, &mut buf)
934        .map_err(|_| VaultError::EncryptionFailed)?;
935    let mut out = Vec::with_capacity(4 + NONCE_LEN + TAG_LEN + buf.len());
936    out.write_all(&RAW_MAGIC)?;
937    out.write_all(nonce.as_slice())?;
938    out.write_all(tag.as_slice())?;
939    out.write_all(&buf)?;
940    buf.zeroize();
941    Ok(out)
942}
943
944pub fn decrypt_raw_blob(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
945    if data.len() < 4 + NONCE_LEN + TAG_LEN {
946        return Err(VaultError::TooSmall);
947    }
948    if data[0..4] != RAW_MAGIC {
949        return Err(VaultError::BadMagic);
950    }
951    let nonce = Nonce::from_slice(&data[4..4 + NONCE_LEN]);
952    let tag = Tag::from_slice(&data[4 + NONCE_LEN..4 + NONCE_LEN + TAG_LEN]);
953    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
954    let mut buf = data[4 + NONCE_LEN + TAG_LEN..].to_vec();
955    cipher
956        .decrypt_in_place_detached(nonce, RAW_AAD, &mut buf, tag)
957        .map_err(|_| VaultError::DecryptionFailed)?;
958    Ok(buf)
959}
960
961// ── Tests ──
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn round_trip() {
969        let mut vault = Vault::new();
970        vault.set("API_KEY", "sk-secret-123");
971        vault.set("DB_URL", "postgres://localhost/mydb");
972
973        let encrypted = vault.encrypt("test-pass").unwrap();
974        let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
975
976        assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
977        assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
978        assert_eq!(decrypted.len(), 2);
979    }
980
981    #[test]
982    fn wrong_passphrase() {
983        let vault = Vault::new();
984        let encrypted = vault.encrypt("correct").unwrap();
985        assert!(matches!(
986            Vault::decrypt(&encrypted, "wrong"),
987            Err(VaultError::DecryptionFailed)
988        ));
989    }
990
991    #[test]
992    fn tamper_detection() {
993        let mut vault = Vault::new();
994        vault.set("KEY", "value");
995        let mut encrypted = vault.encrypt("pass").unwrap();
996        // Flip a byte in the ciphertext
997        if let Some(last) = encrypted.last_mut() {
998            *last ^= 0xFF;
999        }
1000        assert!(Vault::decrypt(&encrypted, "pass").is_err());
1001    }
1002
1003    #[test]
1004    fn fresh_nonce_per_encrypt() {
1005        let vault = Vault::new();
1006        let a = vault.encrypt("pass").unwrap();
1007        let b = vault.encrypt("pass").unwrap();
1008        // Same data, different ciphertext (different salt + nonce)
1009        assert_ne!(a, b);
1010    }
1011
1012    #[test]
1013    fn shell_escaping() {
1014        let mut vault = Vault::new();
1015        vault.set("KEY", "it's a \"test\"");
1016        let exports = vault.to_shell_exports();
1017        assert!(exports.contains("'it'\\''s a \"test\"'"));
1018    }
1019
1020    #[test]
1021    fn valid_keys() {
1022        assert!(is_valid_key("API_KEY"));
1023        assert!(is_valid_key("key123"));
1024        assert!(is_valid_key("prod-db-password"));   // kebab-case (GSM-valid)
1025        assert!(is_valid_key("metatron-enterprise-lock"));
1026        assert!(!is_valid_key(""));
1027        assert!(!is_valid_key("has space"));
1028        assert!(!is_valid_key("has.dot"));           // dots are NOT GSM-valid
1029        assert!(!is_valid_key("has/slash"));
1030    }
1031
1032    // ── QVLT v2 ──
1033
1034    fn zv(s: &str) -> Zeroizing<Vec<u8>> {
1035        Zeroizing::new(s.as_bytes().to_vec())
1036    }
1037
1038    fn test_master() -> MasterSecret {
1039        // Fixed salt so tests are deterministic where it matters.
1040        MasterSecret::derive("test-pass", &[7u8; SALT_LEN])
1041    }
1042
1043    fn sample_file(master: &MasterSecret) -> Vec<u8> {
1044        v2_create(
1045            master,
1046            &[
1047                ("API_KEY".into(), zv("sk-secret-123")),
1048                ("DB_URL".into(), zv("postgres://localhost/mydb")),
1049                ("proj/TOKEN".into(), zv("t-42")),
1050            ],
1051        )
1052        .unwrap()
1053    }
1054
1055    #[test]
1056    fn v2_round_trip_one() {
1057        let m = test_master();
1058        let file = sample_file(&m);
1059        let r = VaultReader::open(file, &m).unwrap();
1060        assert_eq!(r.len(), 3);
1061        assert_eq!(&*r.decrypt_one(&m, "API_KEY").unwrap(), b"sk-secret-123");
1062        assert_eq!(&*r.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1063        assert!(matches!(r.decrypt_one(&m, "NOPE"), Err(VaultError::NotFound)));
1064    }
1065
1066    #[test]
1067    fn v2_wrong_passphrase() {
1068        let m = test_master();
1069        let file = sample_file(&m);
1070        let wrong = MasterSecret::derive("wrong", &[7u8; SALT_LEN]);
1071        // MAC key differs → open fails before any entry decryption.
1072        assert!(matches!(
1073            VaultReader::open(file, &wrong),
1074            Err(VaultError::DecryptionFailed)
1075        ));
1076    }
1077
1078    #[test]
1079    fn v2_salt_mismatch_rejected() {
1080        let m = test_master();
1081        let file = sample_file(&m);
1082        // A master derived under a different salt (stale broker after rekey).
1083        let stale = MasterSecret::derive("test-pass", &[9u8; SALT_LEN]);
1084        assert!(matches!(
1085            VaultReader::open(file, &stale),
1086            Err(VaultError::DecryptionFailed)
1087        ));
1088    }
1089
1090    #[test]
1091    fn v2_ciphertext_tamper_detected() {
1092        let m = test_master();
1093        let mut file = sample_file(&m);
1094        let n = file.len();
1095        file[n - MAC_LEN - 1] ^= 0xFF; // last ciphertext byte
1096        assert!(VaultReader::open(file, &m).is_err());
1097    }
1098
1099    #[test]
1100    fn v2_record_deletion_detected() {
1101        let m = test_master();
1102        let file = sample_file(&m);
1103        // Forge: drop the middle record by rebuilding without re-MACing.
1104        let r = VaultReader::open(file.clone(), &m).unwrap();
1105        let victim = r.records.iter().find(|r| r.name == "DB_URL").unwrap();
1106        let mut forged = Vec::new();
1107        forged.extend_from_slice(&file[..victim.rec_off]);
1108        forged.extend_from_slice(&file[victim.end()..]);
1109        forged[24..28].copy_from_slice(&2u32.to_be_bytes());
1110        assert!(VaultReader::open(forged, &m).is_err());
1111    }
1112
1113    #[test]
1114    fn v2_transplant_rejected_by_aad() {
1115        // Even with a VALID manifest MAC, a ciphertext moved under another name
1116        // must fail: the AAD binds the name into the AEAD itself. Swap the
1117        // crypto material (nonce+tag+ctlen+ct) of two same-bucket entries,
1118        // then re-MAC with the real key — simulating a buggy code path that
1119        // re-MACs without noticing the transplant.
1120        let m = test_master();
1121        let f2 = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1122        let r2 = VaultReader::open(f2.clone(), &m).unwrap();
1123        let ra = &r2.records[0];
1124        let rb = &r2.records[1];
1125        let mut forged = f2.clone();
1126        forged[ra.nonce_off..ra.end()].copy_from_slice(&f2[rb.nonce_off..rb.end()]);
1127        forged[rb.nonce_off..rb.end()].copy_from_slice(&f2[ra.nonce_off..ra.end()]);
1128        // Re-MAC the forged file (attacker without keys can't — but AAD must
1129        // hold even against a buggy path that re-MACs, so simulate with keys).
1130        let body_end = forged.len() - MAC_LEN;
1131        let mk = m.mac_key();
1132        let mut mac = new_hmac(mk.as_ref());
1133        mac.update(&forged[..body_end]);
1134        let tag = mac.finalize().into_bytes();
1135        forged[body_end..].copy_from_slice(&tag);
1136        let rf = VaultReader::open(forged, &m).unwrap();
1137        assert!(rf.decrypt_one(&m, "AAA").is_err());
1138        assert!(rf.decrypt_one(&m, "BBB").is_err());
1139    }
1140
1141    #[test]
1142    fn v2_reorder_rejected() {
1143        // Unsorted records are a structural error before MAC verification.
1144        let m = test_master();
1145        let f = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1146        let r = VaultReader::open(f.clone(), &m).unwrap();
1147        let (ra, rb) = (&r.records[0], &r.records[1]);
1148        let mut forged = f[..V2_HEADER_LEN].to_vec();
1149        forged.extend_from_slice(&f[rb.rec_off..rb.end()]);
1150        forged.extend_from_slice(&f[ra.rec_off..ra.end()]);
1151        let body_end = forged.len();
1152        let mk = m.mac_key();
1153        let mut mac = new_hmac(mk.as_ref());
1154        mac.update(&forged);
1155        forged.extend_from_slice(&mac.finalize().into_bytes());
1156        let _ = body_end;
1157        assert!(matches!(
1158            VaultReader::open(forged, &m),
1159            Err(VaultError::MalformedData)
1160        ));
1161    }
1162
1163    #[test]
1164    fn v2_truncation_and_bounds() {
1165        let m = test_master();
1166        let file = sample_file(&m);
1167        // Truncations at every interesting boundary fail, never panic.
1168        for cut in [0, 3, V2_HEADER_LEN - 1, V2_HEADER_LEN, V2_HEADER_LEN + 5, file.len() - 1] {
1169            assert!(VaultReader::open(file[..cut].to_vec(), &m).is_err());
1170        }
1171        // Absurd declared ct length must fail bounds, not allocate.
1172        let r = VaultReader::open(file.clone(), &m).unwrap();
1173        let rec = &r.records[0];
1174        let mut forged = file.clone();
1175        let off = rec.tag_off() + TAG_LEN;
1176        forged[off..off + 4].copy_from_slice(&u32::MAX.to_be_bytes());
1177        assert!(VaultReader::open(forged, &m).is_err());
1178    }
1179
1180    #[test]
1181    fn v2_unknown_scheme_and_flags_fail_closed() {
1182        let m = test_master();
1183        let file = sample_file(&m);
1184        let mut s = file.clone();
1185        s[V2_HEADER_LEN] = 0x02; // first record's scheme byte
1186        assert!(matches!(
1187            VaultReader::open(s, &m),
1188            Err(VaultError::UnknownScheme(0x02))
1189        ));
1190        let mut fl = file.clone();
1191        fl[6] = 0x80; // flag bit
1192        assert!(matches!(
1193            VaultReader::open(fl, &m),
1194            Err(VaultError::UnsupportedFlags(_))
1195        ));
1196        let mut kdf = file;
1197        kdf[5] = 0x02;
1198        assert!(matches!(
1199            VaultReader::open(kdf, &m),
1200            Err(VaultError::UnsupportedKdf(0x02))
1201        ));
1202    }
1203
1204    #[test]
1205    fn v2_padding_buckets_hide_length() {
1206        let m = test_master();
1207        // 1-byte and 27-byte values land in the same 32-byte bucket (4-byte
1208        // length prefix + value ≤ 32) → identical ct length on the wire.
1209        let f1 = v2_create(&m, &[("K".into(), zv("a"))]).unwrap();
1210        let f2 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyza"))]).unwrap();
1211        assert_eq!(f1.len(), f2.len());
1212        // 29 bytes crosses into the next bucket.
1213        let f3 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyzabc"))]).unwrap();
1214        assert_eq!(f3.len(), f1.len() + PAD_BLOCK);
1215        // Round-trips exactly (true length recovered, padding stripped).
1216        let r = VaultReader::open(f3, &m).unwrap();
1217        assert_eq!(&*r.decrypt_one(&m, "K").unwrap(), b"abcdefghijklmnopqrstuvwxyzabc");
1218    }
1219
1220    #[test]
1221    fn v2_splice_upsert_delete_without_reading() {
1222        let m = test_master();
1223        let file = sample_file(&m);
1224        let r = VaultReader::open(file, &m).unwrap();
1225        let out = r
1226            .splice(
1227                &m,
1228                &[("NEW_KEY".into(), zv("fresh")), ("API_KEY".into(), zv("rotated"))],
1229                &["DB_URL".to_string()],
1230            )
1231            .unwrap();
1232        let r2 = VaultReader::open(out, &m).unwrap();
1233        assert_eq!(
1234            r2.names().collect::<Vec<_>>(),
1235            vec!["API_KEY", "NEW_KEY", "proj/TOKEN"]
1236        );
1237        assert_eq!(&*r2.decrypt_one(&m, "API_KEY").unwrap(), b"rotated");
1238        assert_eq!(&*r2.decrypt_one(&m, "NEW_KEY").unwrap(), b"fresh");
1239        // Untouched record survived byte-verbatim re-emission.
1240        assert_eq!(&*r2.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1241    }
1242
1243    #[test]
1244    fn v2_duplicate_name_rejected() {
1245        let m = test_master();
1246        let f = v2_create(&m, &[("AAA".into(), zv("x")), ("AAB".into(), zv("x"))]).unwrap();
1247        let r = VaultReader::open(f.clone(), &m).unwrap();
1248        let ra = &r.records[0];
1249        // Duplicate AAA by overwriting AAB's name bytes in place (same length).
1250        let mut forged = f.clone();
1251        let rb = &r.records[1];
1252        forged[rb.rec_off + 3..rb.rec_off + 6].copy_from_slice(b"AAA");
1253        let _ = ra;
1254        assert!(matches!(
1255            VaultReader::open(forged, &m),
1256            Err(VaultError::MalformedData)
1257        ));
1258    }
1259
1260    #[test]
1261    fn v2_storage_name_grammar() {
1262        assert!(is_valid_storage_name("API_KEY"));
1263        assert!(is_valid_storage_name("proj/API_KEY"));
1264        assert!(is_valid_storage_name("my.proj/prod-db-password"));
1265        assert!(!is_valid_storage_name("a/b/c"));
1266        assert!(!is_valid_storage_name("/KEY"));
1267        assert!(!is_valid_storage_name("proj/"));
1268        assert!(!is_valid_storage_name(""));
1269        assert!(!is_valid_storage_name("has.dot")); // dots invalid in bare keys
1270    }
1271
1272    // ── Cryptographic known-answer tests (KATs) ──
1273    //
1274    // Round-trip tests have a blind spot: if a derivation detail (HKDF info
1275    // string, AAD layout, padding, MAC input) drifts, encrypt AND decrypt
1276    // drift together and every round-trip stays green — while every REAL
1277    // vault on disk becomes unreadable. These tests pin the exact bytes.
1278
1279    fn hex(bytes: &[u8]) -> String {
1280        bytes.iter().map(|b| format!("{b:02x}")).collect()
1281    }
1282
1283    /// Full-composition KAT: a vault file committed to the repo (generated
1284    /// 2026-07-24 via the CLI) must decrypt to these exact contents FOREVER.
1285    /// If this test fails, the change breaks every existing vault — do not
1286    /// "fix" the expectations; fix the code (or write a migration).
1287    #[test]
1288    fn golden_file_kat() {
1289        let data = include_bytes!("../tests/golden/golden_v2.qvlt").to_vec();
1290        let salt = v2_salt(&data).unwrap();
1291        let m = MasterSecret::derive("golden-pass-do-not-change", &salt);
1292        let r = VaultReader::open(data, &m).unwrap();
1293        assert_eq!(
1294            r.names().collect::<Vec<_>>(),
1295            vec!["BIGKEY", "GREETING", "MULTILINE", "app1/API_KEY", "app2/API_KEY"]
1296        );
1297        assert_eq!(&*r.decrypt_one(&m, "GREETING").unwrap(), b"hello-world");
1298        assert_eq!(&*r.decrypt_one(&m, "MULTILINE").unwrap(), b"line1\nline2");
1299        assert_eq!(&*r.decrypt_one(&m, "app1/API_KEY").unwrap(), b"value-app1");
1300        assert_eq!(&*r.decrypt_one(&m, "app2/API_KEY").unwrap(), b"value-app2");
1301        assert_eq!(
1302            &*r.decrypt_one(&m, "BIGKEY").unwrap(),
1303            b"0123456789012345678901234567890123456789012345678901234567890123"
1304        );
1305    }
1306
1307    /// Derivation-chain KATs, cross-checked against an INDEPENDENT
1308    /// implementation (Python hashlib/hmac, computed 2026-07-24). Pins the
1309    /// PBKDF2 parameters and every HKDF info string: any accidental change
1310    /// to "qvlt2:entry:"/"qvlt2:manifest"/"qvlt2:registry", the length
1311    /// prefix, or the salt wiring fails here with a precise finger.
1312    #[test]
1313    fn derivation_kats_cross_impl() {
1314        let salt: [u8; SALT_LEN] = core::array::from_fn(|i| i as u8);
1315        let m = MasterSecret::derive("golden-pass", &salt);
1316        assert_eq!(
1317            hex(m.secret.as_ref()),
1318            "15bd048606d475f651612dd37b8dcd2e8e11534c8b689d0a6a44d1b8819eff7d"
1319        );
1320        assert_eq!(
1321            hex(m.entry_key("API_KEY").as_ref()),
1322            "8c92f6013a1f708304488ce1d7237b1389d37c3194d3c05fa9dddc1350d05a5c"
1323        );
1324        assert_eq!(
1325            hex(m.mac_key().as_ref()),
1326            "ae55458d0fb8334b5642ff9db564921ecfd59454e31b8fe33d909e4d71ff0fb9"
1327        );
1328        assert_eq!(
1329            hex(m.registry_key().as_ref()),
1330            "eb12b87f7bb7e45749bc5f998d1be4f34a04ef238ca881ecb4eccdd71d6acf4d"
1331        );
1332    }
1333
1334    /// RFC 5869 Appendix A.1 (HKDF-SHA256, basic case) — anchors our use of
1335    /// the `hkdf` crate to the standard's own test vector.
1336    #[test]
1337    fn hkdf_rfc5869_a1() {
1338        let ikm = [0x0bu8; 22];
1339        let salt: Vec<u8> = (0x00..=0x0c).collect();
1340        let info: Vec<u8> = (0xf0..=0xf9).collect();
1341        let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
1342        let mut okm = [0u8; 42];
1343        hk.expand(&info, &mut okm).unwrap();
1344        assert_eq!(
1345            hex(&okm),
1346            "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
1347        );
1348    }
1349
1350    /// RFC 4231 test case 1 (HMAC-SHA256) — anchors the manifest-MAC
1351    /// primitive to the standard's own test vector.
1352    #[test]
1353    fn hmac_rfc4231_case1() {
1354        let mut mac = new_hmac(&[0x0bu8; 20]);
1355        mac.update(b"Hi There");
1356        assert_eq!(
1357            hex(&mac.finalize().into_bytes()),
1358            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1359        );
1360    }
1361
1362    /// Deterministic mutation smoke-fuzz: thousands of corrupted / truncated /
1363    /// garbage variants of a valid file must never panic the parser — every
1364    /// outcome is Ok or a clean Err. (The real coverage-guided harness lives
1365    /// in fuzz/; this keeps a panic-safety floor in plain `cargo test`.)
1366    #[test]
1367    fn mutation_smoke_no_panic() {
1368        let m = test_master();
1369        let base = sample_file(&m);
1370        // xorshift64* — deterministic, no external RNG dep.
1371        let mut s: u64 = 0x9E3779B97F4A7C15;
1372        let mut rng = move || {
1373            s ^= s >> 12;
1374            s ^= s << 25;
1375            s ^= s >> 27;
1376            s = s.wrapping_mul(0x2545F4914F6CDD1D);
1377            s
1378        };
1379        for i in 0..5000u64 {
1380            let mut d = base.clone();
1381            match i % 4 {
1382                0 => {
1383                    // flip 1–3 random bytes
1384                    for _ in 0..=(rng() % 3) {
1385                        let idx = (rng() as usize) % d.len();
1386                        d[idx] ^= (rng() as u8) | 1;
1387                    }
1388                }
1389                1 => {
1390                    // truncate at a random point
1391                    d.truncate((rng() as usize) % (d.len() + 1));
1392                }
1393                2 => {
1394                    // splice random garbage into a random window
1395                    let start = (rng() as usize) % d.len();
1396                    let end = (start + 1 + (rng() as usize) % 64).min(d.len());
1397                    for b in &mut d[start..end] {
1398                        *b = rng() as u8;
1399                    }
1400                }
1401                _ => {
1402                    // pure garbage of random length
1403                    let n = (rng() as usize) % 600;
1404                    d = (0..n).map(|_| rng() as u8).collect();
1405                }
1406            }
1407            if let Ok(r) = VaultReader::open(d, &m) {
1408                for name in r.names().map(String::from).collect::<Vec<_>>() {
1409                    let _ = r.decrypt_one(&m, &name);
1410                }
1411            }
1412        }
1413    }
1414
1415    #[test]
1416    fn raw_blob_round_trip_and_tamper() {
1417        let key = [42u8; 32];
1418        let enc = encrypt_raw_blob(b"registry-json", &key).unwrap();
1419        assert_eq!(decrypt_raw_blob(&enc, &key).unwrap(), b"registry-json");
1420        assert!(decrypt_raw_blob(&enc, &[43u8; 32]).is_err());
1421        let mut t = enc.clone();
1422        let n = t.len();
1423        t[n - 1] ^= 1;
1424        assert!(decrypt_raw_blob(&t, &key).is_err());
1425    }
1426
1427    #[test]
1428    fn v1_still_readable() {
1429        // Migration path: v1 files remain decryptable.
1430        let mut vault = Vault::new();
1431        vault.set("OLD", "value");
1432        let v1 = vault.encrypt("pass").unwrap();
1433        assert!(is_v1(&v1));
1434        assert!(!is_v2(&v1));
1435        let back = Vault::decrypt(&v1, "pass").unwrap();
1436        assert_eq!(back.get("OLD"), Some("value"));
1437    }
1438
1439    #[test]
1440    fn parse_env() {
1441        let input = r#"
1442export API_KEY="sk-123"
1443DB_URL=postgres://localhost
1444# comment
1445export EMPTY=
1446
1447BARE=value
1448"#;
1449        let pairs = parse_env_lines(input);
1450        assert_eq!(pairs.len(), 3);
1451        // parse_env_lines returns in file order, not sorted
1452        assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
1453        assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
1454        assert_eq!(pairs[2], ("BARE".into(), "value".into()));
1455    }
1456}