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::{AeadInOut, Generate, KeyInit};
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        getrandom::fill(&mut salt).expect("OS RNG failure");
211        let nonce = Nonce::generate();
212
213        let key = derive_key(passphrase, &salt);
214        let cipher = Aes256Gcm::new(&key);
215
216        let tag = cipher
217            .encrypt_inout_detached(&nonce, b"", plaintext.as_mut_slice().into())
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<_> = nonce_bytes.try_into().expect("nonce length is structural");
257        let tag: &Tag = tag_bytes.try_into().expect("tag length is structural");
258
259        let mut buf = ciphertext.to_vec();
260        cipher
261            .decrypt_inout_detached(nonce, b"", buf.as_mut_slice().into(), 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    getrandom::fill(&mut buf).expect("OS RNG failure");
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    getrandom::fill(&mut salt).expect("OS RNG failure");
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    getrandom::fill(&mut salt).expect("OS RNG failure");
393    let nonce = Nonce::generate();
394    let key = derive_key(passphrase, &salt);
395    let cipher = Aes256Gcm::new(&key);
396    let tag = cipher
397        .encrypt_inout_detached(&nonce, b"", buf.as_mut_slice().into())
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<_> = nonce_bytes.try_into().expect("nonce length is structural");
431    let tag: &Tag = tag_bytes.try_into().expect("tag length is structural");
432
433    let mut buf = ciphertext.to_vec();
434    cipher
435        .decrypt_inout_detached(nonce, b"", buf.as_mut_slice().into(), 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>::try_from(&key[..]).expect("PBKDF2 output is 32 bytes")
498}
499
500// ═══════════════════════════════════════════════════════════════════════════
501// QVLT v2 — per-entry encryption (QVLT2_SPEC.md)
502// ═══════════════════════════════════════════════════════════════════════════
503
504use hmac::Mac;
505
506/// HMAC constructor. Since crypto-common 0.2 there is ONE `KeyInit` trait
507/// shared by the cipher and the MAC, so the old `Mac`-vs-`KeyInit`
508/// disambiguation is gone.
509fn new_hmac(key: &[u8]) -> HmacSha256 {
510    <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length")
511}
512
513/// v2 version byte.
514pub const V2_VERSION: u8 = 0x02;
515/// KDF id: PBKDF2-HMAC-SHA256 @ [`ITERATIONS`].
516pub const KDF_PBKDF2: u8 = 0x01;
517/// Entry key scheme: HKDF from the master secret.
518pub const SCHEME_HKDF: u8 = 0x01;
519/// v2 header: magic(4) + version(1) + kdf(1) + flags(2) + salt(16) + count(4).
520pub const V2_HEADER_LEN: usize = 4 + 1 + 1 + 2 + SALT_LEN + 4; // 28
521/// Trailing manifest MAC length (HMAC-SHA256).
522pub const MAC_LEN: usize = 32;
523/// Value padding bucket (spec §4.2): plaintext bodies are 4-byte true-length
524/// prefix + value + zero pad, rounded up to a multiple of this.
525pub const PAD_BLOCK: usize = 32;
526/// Max storage-name length: project(256) + '/'(1) + key(256).
527pub const MAX_NAME_LEN: usize = 513;
528/// Largest legal padded body: 4-byte length prefix + MAX_VALUE_LEN, bucketed.
529const MAX_PADDED: usize = (4 + MAX_VALUE_LEN).div_ceil(PAD_BLOCK) * PAD_BLOCK;
530
531type HmacSha256 = hmac::Hmac<sha2::Sha256>;
532
533/// A storage name is either `KEY` or `project/KEY` (spec §4.1) — at most one `/`.
534pub fn is_valid_storage_name(name: &str) -> bool {
535    match name.split_once('/') {
536        Some((project, key)) => is_valid_project(project) && is_valid_key(key),
537        None => is_valid_key(name),
538    }
539}
540
541/// The vault-level KDF output. Derive ONCE per open (the expensive step), then
542/// zeroize the passphrase — every other key HKDF-derives from this (spec §4.3).
543/// Carries the salt it was derived under so entry/mac/registry keys need no
544/// extra context.
545pub struct MasterSecret {
546    secret: Zeroizing<[u8; 32]>,
547    salt: [u8; SALT_LEN],
548}
549
550impl MasterSecret {
551    /// PBKDF2-HMAC-SHA256 @ 600k over the passphrase. The caller should drop
552    /// (zeroize) the passphrase immediately after this returns.
553    pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Self {
554        let secret = Zeroizing::new(pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
555            passphrase.as_bytes(),
556            salt,
557            ITERATIONS,
558        ));
559        Self { secret, salt: *salt }
560    }
561
562    /// Wrap an already-random 32-byte key as the master secret (the lease
563    /// path, LEASE_DESIGN.md §4). A full-entropy key needs no stretching, so
564    /// PBKDF2 is skipped; every HKDF derivation downstream (entry keys,
565    /// manifest MAC) is identical to the passphrase path, and the container
566    /// format is byte-for-byte the same QVLT v2.
567    pub fn from_raw_key(key: &[u8; 32], salt: &[u8; SALT_LEN]) -> Self {
568        Self { secret: Zeroizing::new(*key), salt: *salt }
569    }
570
571    pub fn salt(&self) -> &[u8; SALT_LEN] {
572        &self.salt
573    }
574
575    fn hkdf(&self, info: &[u8]) -> Zeroizing<[u8; 32]> {
576        let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&self.salt), self.secret.as_ref());
577        let mut okm = Zeroizing::new([0u8; 32]);
578        hk.expand(info, okm.as_mut())
579            .expect("32 bytes is a valid HKDF-SHA256 output length");
580        okm
581    }
582
583    /// Per-entry key. Info length-prefixes the name so entry infos are
584    /// prefix-free among themselves and against the fixed labels (spec §4.3).
585    fn entry_key(&self, name: &str) -> Zeroizing<[u8; 32]> {
586        let mut info = Vec::with_capacity(14 + name.len());
587        info.extend_from_slice(b"qvlt2:entry:");
588        info.extend_from_slice(&(name.len() as u16).to_be_bytes());
589        info.extend_from_slice(name.as_bytes());
590        self.hkdf(&info)
591    }
592
593    fn mac_key(&self) -> Zeroizing<[u8; 32]> {
594        self.hkdf(b"qvlt2:manifest")
595    }
596
597    /// Key for the raw-key registry container (spec §6.2).
598    pub fn registry_key(&self) -> Zeroizing<[u8; 32]> {
599        self.hkdf(b"qvlt2:registry")
600    }
601}
602
603/// True if the bytes look like a QVLT v2 file (magic + version only — full
604/// validation happens in [`VaultReader::open`]).
605pub fn is_v2(data: &[u8]) -> bool {
606    data.len() >= V2_HEADER_LEN && data[0..4] == MAGIC && data[4] == V2_VERSION
607}
608
609/// True if the bytes look like a legacy v1 file.
610pub fn is_v1(data: &[u8]) -> bool {
611    data.len() >= HEADER_LEN && data[0..4] == MAGIC && data[4] == VERSION
612}
613
614/// Parse a v2 header far enough to return the vault salt — needed BEFORE key
615/// derivation (chicken/egg: the master secret derives from this salt). Also
616/// enforces the fail-closed prefix checks: magic, version, KDF id, zero flags.
617pub fn v2_salt(data: &[u8]) -> Result<[u8; SALT_LEN], VaultError> {
618    if data.len() < V2_HEADER_LEN + MAC_LEN {
619        return Err(VaultError::TooSmall);
620    }
621    if data[0..4] != MAGIC {
622        return Err(VaultError::BadMagic);
623    }
624    if data[4] != V2_VERSION {
625        return Err(VaultError::UnsupportedVersion(data[4]));
626    }
627    if data[5] != KDF_PBKDF2 {
628        return Err(VaultError::UnsupportedKdf(data[5]));
629    }
630    let flags = u16::from_be_bytes([data[6], data[7]]);
631    if flags != 0 {
632        return Err(VaultError::UnsupportedFlags(flags));
633    }
634    let mut salt = [0u8; SALT_LEN];
635    salt.copy_from_slice(&data[8..8 + SALT_LEN]);
636    Ok(salt)
637}
638
639/// One parsed (not decrypted) record: byte offsets into the file image.
640struct RecordMeta {
641    scheme: u8,
642    name: String,
643    /// Offset of the record's first byte (the scheme byte).
644    rec_off: usize,
645    /// Offset of the nonce (name end).
646    nonce_off: usize,
647    ct_len: usize,
648}
649
650impl RecordMeta {
651    fn tag_off(&self) -> usize {
652        self.nonce_off + NONCE_LEN
653    }
654    fn ct_off(&self) -> usize {
655        self.tag_off() + TAG_LEN + 4
656    }
657    fn end(&self) -> usize {
658        self.ct_off() + self.ct_len
659    }
660}
661
662/// A validated v2 vault image supporting selective decryption and no-read
663/// splicing (spec §5). Holds the raw bytes; values stay ciphertext until
664/// [`Self::decrypt_one`] is called for a specific name.
665pub struct VaultReader {
666    data: Vec<u8>,
667    salt: [u8; SALT_LEN],
668    records: Vec<RecordMeta>,
669}
670
671impl VaultReader {
672    /// Full structural validation + manifest MAC verification (spec §4,
673    /// normative order: bounds before any length-driven allocation, exact
674    /// tiling, sorted unique valid names, then MAC).
675    pub fn open(data: Vec<u8>, master: &MasterSecret) -> Result<Self, VaultError> {
676        let salt = v2_salt(&data)?;
677        if salt != master.salt {
678            // Wrong MasterSecret for this file (e.g. stale broker after rekey).
679            return Err(VaultError::DecryptionFailed);
680        }
681        let count = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as usize;
682        let body_end = data.len() - MAC_LEN;
683
684        let mut records = Vec::new();
685        let mut pos = V2_HEADER_LEN;
686        for _ in 0..count {
687            // Every length is bounds-checked against the remaining region
688            // BEFORE any slice/allocation driven by it (spec §4).
689            if pos + 3 > body_end {
690                return Err(VaultError::MalformedData);
691            }
692            let scheme = data[pos];
693            if scheme != SCHEME_HKDF {
694                // v2 readers know only scheme 0x01; unknown schemes have
695                // unknown layouts, so the whole parse fails closed (§4.6).
696                return Err(VaultError::UnknownScheme(scheme));
697            }
698            let name_len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
699            if name_len == 0 || name_len > MAX_NAME_LEN || pos + 3 + name_len + NONCE_LEN + TAG_LEN + 4 > body_end {
700                return Err(VaultError::MalformedData);
701            }
702            let name = std::str::from_utf8(&data[pos + 3..pos + 3 + name_len])
703                .map_err(|_| VaultError::MalformedData)?
704                .to_string();
705            if !is_valid_storage_name(&name) {
706                return Err(VaultError::MalformedData);
707            }
708            if let Some(prev) = records.last() {
709                let prev: &RecordMeta = prev;
710                if prev.name.as_bytes() >= name.as_bytes() {
711                    // Strict byte-lexicographic order ⇒ sorted AND unique.
712                    return Err(VaultError::MalformedData);
713                }
714            }
715            let nonce_off = pos + 3 + name_len;
716            let ct_len_off = nonce_off + NONCE_LEN + TAG_LEN;
717            let ct_len = u32::from_be_bytes([
718                data[ct_len_off],
719                data[ct_len_off + 1],
720                data[ct_len_off + 2],
721                data[ct_len_off + 3],
722            ]) as usize;
723            if ct_len == 0
724                || ct_len % PAD_BLOCK != 0
725                || ct_len > MAX_PADDED
726                || ct_len_off + 4 + ct_len > body_end
727            {
728                return Err(VaultError::MalformedData);
729            }
730            records.push(RecordMeta { scheme, name, rec_off: pos, nonce_off, ct_len });
731            pos = ct_len_off + 4 + ct_len;
732        }
733        if pos != body_end {
734            // Records must exactly tile the region between header and MAC.
735            return Err(VaultError::MalformedData);
736        }
737
738        let mac_key = master.mac_key();
739        let mut mac = new_hmac(mac_key.as_ref());
740        mac.update(&data[..body_end]);
741        // (spec §4.5: MAC input is file || external_context; context is empty in v2)
742        mac.verify_slice(&data[body_end..])
743            .map_err(|_| VaultError::DecryptionFailed)?;
744
745        Ok(Self { data, salt, records })
746    }
747
748    /// Entry names, sorted. No decryption.
749    pub fn names(&self) -> impl Iterator<Item = &str> {
750        self.records.iter().map(|r| r.name.as_str())
751    }
752
753    pub fn len(&self) -> usize {
754        self.records.len()
755    }
756
757    pub fn is_empty(&self) -> bool {
758        self.records.is_empty()
759    }
760
761    pub fn contains(&self, name: &str) -> bool {
762        self.find(name).is_some()
763    }
764
765    fn find(&self, name: &str) -> Option<&RecordMeta> {
766        self.records
767            .binary_search_by(|r| r.name.as_str().cmp(name))
768            .ok()
769            .map(|i| &self.records[i])
770    }
771
772    /// Decrypt exactly one record (G1). Every other entry stays ciphertext.
773    /// Returns the true-length value bytes.
774    pub fn decrypt_one(
775        &self,
776        master: &MasterSecret,
777        name: &str,
778    ) -> Result<Zeroizing<Vec<u8>>, VaultError> {
779        let rec = self.find(name).ok_or(VaultError::NotFound)?;
780        let key = master.entry_key(name);
781        let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("derived keys are 32 bytes"));
782        let nonce: &Nonce<_> = (&self.data[rec.nonce_off..rec.nonce_off + NONCE_LEN])
783            .try_into()
784            .expect("nonce length is structural");
785        let tag: &Tag = (&self.data[rec.tag_off()..rec.tag_off() + TAG_LEN])
786            .try_into()
787            .expect("tag length is structural");
788        let aad = record_aad(rec.scheme, name);
789
790        let mut body =
791            Zeroizing::new(self.data[rec.ct_off()..rec.ct_off() + rec.ct_len].to_vec());
792        cipher
793            .decrypt_inout_detached(nonce, &aad, (body.as_mut_slice()).into(), tag)
794            .map_err(|_| VaultError::DecryptionFailed)?;
795
796        let true_len =
797            u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
798        if true_len > MAX_VALUE_LEN || 4 + true_len > body.len() {
799            return Err(VaultError::MalformedData);
800        }
801        Ok(Zeroizing::new(body[4..4 + true_len].to_vec()))
802    }
803
804    /// Decrypt every entry — for the inherently whole-vault operations
805    /// (`list`, `env`, `export`, migration). Discouraged elsewhere.
806    pub fn decrypt_all(&self, master: &MasterSecret) -> Result<Vault, VaultError> {
807        let mut entries = BTreeMap::new();
808        for r in &self.records {
809            let v = self.decrypt_one(master, &r.name)?;
810            let s = String::from_utf8_lossy(&v).into_owned();
811            entries.insert(r.name.clone(), s);
812        }
813        Ok(Vault { entries })
814    }
815
816    /// Rebuild the file with `upserts` applied and `deletes` removed — WITHOUT
817    /// decrypting any untouched entry (spec §5.2): their record bytes are
818    /// re-emitted verbatim; only the manifest MAC is recomputed.
819    pub fn splice(
820        &self,
821        master: &MasterSecret,
822        upserts: &[(String, Zeroizing<Vec<u8>>)],
823        deletes: &[String],
824    ) -> Result<Vec<u8>, VaultError> {
825        enum Src<'a> {
826            Keep(&'a RecordMeta),
827            New(&'a str, &'a [u8]),
828        }
829        let mut merged: BTreeMap<&str, Src> = self
830            .records
831            .iter()
832            .map(|r| (r.name.as_str(), Src::Keep(r)))
833            .collect();
834        for name in deletes {
835            merged.remove(name.as_str());
836        }
837        for (name, value) in upserts {
838            if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
839                return Err(VaultError::MalformedData);
840            }
841            merged.insert(&name[..], Src::New(name, value));
842        }
843
844        let mut out = Vec::new();
845        write_v2_header(&mut out, &self.salt, merged.len() as u32)?;
846        for (name, src) in &merged {
847            match src {
848                Src::Keep(rec) => out.extend_from_slice(&self.data[rec.rec_off..rec.end()]),
849                Src::New(name, value) => write_record(&mut out, master, name, value)?,
850            }
851            let _ = name;
852        }
853        append_mac(&mut out, master);
854        Ok(out)
855    }
856}
857
858fn record_aad(scheme: u8, name: &str) -> Vec<u8> {
859    let mut aad = Vec::with_capacity(2 + name.len());
860    aad.push(V2_VERSION);
861    aad.push(scheme);
862    aad.extend_from_slice(name.as_bytes());
863    aad
864}
865
866fn write_v2_header(out: &mut Vec<u8>, salt: &[u8; SALT_LEN], count: u32) -> Result<(), VaultError> {
867    out.write_all(&MAGIC)?;
868    out.write_all(&[V2_VERSION, KDF_PBKDF2, 0, 0])?;
869    out.write_all(salt)?;
870    out.write_all(&count.to_be_bytes())?;
871    Ok(())
872}
873
874fn write_record(
875    out: &mut Vec<u8>,
876    master: &MasterSecret,
877    name: &str,
878    value: &[u8],
879) -> Result<(), VaultError> {
880    // Padded body: u32 true length + value + zero pad to the bucket (§4.2).
881    let padded = (4 + value.len()).div_ceil(PAD_BLOCK) * PAD_BLOCK;
882    let mut body = Zeroizing::new(vec![0u8; padded]);
883    body[..4].copy_from_slice(&(value.len() as u32).to_be_bytes());
884    body[4..4 + value.len()].copy_from_slice(value);
885
886    let key = master.entry_key(name);
887    let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("derived keys are 32 bytes"));
888    let nonce = Nonce::generate();
889    let aad = record_aad(SCHEME_HKDF, name);
890    let tag = cipher
891        .encrypt_inout_detached(&nonce, &aad, (body.as_mut_slice()).into())
892        .map_err(|_| VaultError::EncryptionFailed)?;
893
894    out.push(SCHEME_HKDF);
895    out.write_all(&(name.len() as u16).to_be_bytes())?;
896    out.write_all(name.as_bytes())?;
897    out.write_all(nonce.as_slice())?;
898    out.write_all(tag.as_slice())?;
899    out.write_all(&(padded as u32).to_be_bytes())?;
900    out.write_all(&body)?;
901    Ok(())
902}
903
904fn append_mac(out: &mut Vec<u8>, master: &MasterSecret) {
905    let mac_key = master.mac_key();
906    let mut mac = new_hmac(mac_key.as_ref());
907    mac.update(out);
908    out.extend_from_slice(&mac.finalize().into_bytes());
909}
910
911/// Build a fresh v2 file from scratch (empty vault creation, migration,
912/// rekey). `entries` need not be sorted; names are validated.
913pub fn v2_create(
914    master: &MasterSecret,
915    entries: &[(String, Zeroizing<Vec<u8>>)],
916) -> Result<Vec<u8>, VaultError> {
917    let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
918    for (name, value) in entries {
919        if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
920            return Err(VaultError::MalformedData);
921        }
922        sorted.insert(&name[..], value);
923    }
924    let mut out = Vec::new();
925    write_v2_header(&mut out, &master.salt, sorted.len() as u32)?;
926    for (name, value) in &sorted {
927        write_record(&mut out, master, name, value)?;
928    }
929    append_mac(&mut out, master);
930    Ok(out)
931}
932
933// ── Raw-key blob container (registry v2, spec §6.2) ──
934//
935// The registry moves off direct-passphrase PBKDF2 onto a key HKDF-derived from
936// the master secret — the expensive KDF already happened at the vault level,
937// and it lets the session broker zeroize the passphrase at startup.
938
939const RAW_MAGIC: [u8; 4] = *b"QRG2";
940const RAW_AAD: &[u8] = b"qvlt2:registry";
941
942pub fn encrypt_raw_blob(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
943    let cipher = Aes256Gcm::new(key.try_into().expect("derived keys are 32 bytes"));
944    let nonce = Nonce::generate();
945    let mut buf = plaintext.to_vec();
946    let tag = cipher
947        .encrypt_inout_detached(&nonce, RAW_AAD, buf.as_mut_slice().into())
948        .map_err(|_| VaultError::EncryptionFailed)?;
949    let mut out = Vec::with_capacity(4 + NONCE_LEN + TAG_LEN + buf.len());
950    out.write_all(&RAW_MAGIC)?;
951    out.write_all(nonce.as_slice())?;
952    out.write_all(tag.as_slice())?;
953    out.write_all(&buf)?;
954    buf.zeroize();
955    Ok(out)
956}
957
958pub fn decrypt_raw_blob(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
959    if data.len() < 4 + NONCE_LEN + TAG_LEN {
960        return Err(VaultError::TooSmall);
961    }
962    if data[0..4] != RAW_MAGIC {
963        return Err(VaultError::BadMagic);
964    }
965    let nonce: &Nonce<_> = (&data[4..4 + NONCE_LEN]).try_into().expect("nonce length is structural");
966    let tag: &Tag = (&data[4 + NONCE_LEN..4 + NONCE_LEN + TAG_LEN]).try_into().expect("tag length is structural");
967    let cipher = Aes256Gcm::new(key.try_into().expect("derived keys are 32 bytes"));
968    let mut buf = data[4 + NONCE_LEN + TAG_LEN..].to_vec();
969    cipher
970        .decrypt_inout_detached(nonce, RAW_AAD, buf.as_mut_slice().into(), tag)
971        .map_err(|_| VaultError::DecryptionFailed)?;
972    Ok(buf)
973}
974
975// ── Tests ──
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    #[test]
982    fn round_trip() {
983        let mut vault = Vault::new();
984        vault.set("API_KEY", "sk-secret-123");
985        vault.set("DB_URL", "postgres://localhost/mydb");
986
987        let encrypted = vault.encrypt("test-pass").unwrap();
988        let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
989
990        assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
991        assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
992        assert_eq!(decrypted.len(), 2);
993    }
994
995    #[test]
996    fn wrong_passphrase() {
997        let vault = Vault::new();
998        let encrypted = vault.encrypt("correct").unwrap();
999        assert!(matches!(
1000            Vault::decrypt(&encrypted, "wrong"),
1001            Err(VaultError::DecryptionFailed)
1002        ));
1003    }
1004
1005    #[test]
1006    fn tamper_detection() {
1007        let mut vault = Vault::new();
1008        vault.set("KEY", "value");
1009        let mut encrypted = vault.encrypt("pass").unwrap();
1010        // Flip a byte in the ciphertext
1011        if let Some(last) = encrypted.last_mut() {
1012            *last ^= 0xFF;
1013        }
1014        assert!(Vault::decrypt(&encrypted, "pass").is_err());
1015    }
1016
1017    #[test]
1018    fn fresh_nonce_per_encrypt() {
1019        let vault = Vault::new();
1020        let a = vault.encrypt("pass").unwrap();
1021        let b = vault.encrypt("pass").unwrap();
1022        // Same data, different ciphertext (different salt + nonce)
1023        assert_ne!(a, b);
1024    }
1025
1026    #[test]
1027    fn shell_escaping() {
1028        let mut vault = Vault::new();
1029        vault.set("KEY", "it's a \"test\"");
1030        let exports = vault.to_shell_exports();
1031        assert!(exports.contains("'it'\\''s a \"test\"'"));
1032    }
1033
1034    #[test]
1035    fn valid_keys() {
1036        assert!(is_valid_key("API_KEY"));
1037        assert!(is_valid_key("key123"));
1038        assert!(is_valid_key("prod-db-password"));   // kebab-case (GSM-valid)
1039        assert!(is_valid_key("metatron-enterprise-lock"));
1040        assert!(!is_valid_key(""));
1041        assert!(!is_valid_key("has space"));
1042        assert!(!is_valid_key("has.dot"));           // dots are NOT GSM-valid
1043        assert!(!is_valid_key("has/slash"));
1044    }
1045
1046    // ── QVLT v2 ──
1047
1048    fn zv(s: &str) -> Zeroizing<Vec<u8>> {
1049        Zeroizing::new(s.as_bytes().to_vec())
1050    }
1051
1052    fn test_master() -> MasterSecret {
1053        // Fixed salt so tests are deterministic where it matters.
1054        MasterSecret::derive("test-pass", &[7u8; SALT_LEN])
1055    }
1056
1057    fn sample_file(master: &MasterSecret) -> Vec<u8> {
1058        v2_create(
1059            master,
1060            &[
1061                ("API_KEY".into(), zv("sk-secret-123")),
1062                ("DB_URL".into(), zv("postgres://localhost/mydb")),
1063                ("proj/TOKEN".into(), zv("t-42")),
1064            ],
1065        )
1066        .unwrap()
1067    }
1068
1069    #[test]
1070    fn v2_round_trip_one() {
1071        let m = test_master();
1072        let file = sample_file(&m);
1073        let r = VaultReader::open(file, &m).unwrap();
1074        assert_eq!(r.len(), 3);
1075        assert_eq!(&*r.decrypt_one(&m, "API_KEY").unwrap(), b"sk-secret-123");
1076        assert_eq!(&*r.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1077        assert!(matches!(r.decrypt_one(&m, "NOPE"), Err(VaultError::NotFound)));
1078    }
1079
1080    #[test]
1081    fn v2_wrong_passphrase() {
1082        let m = test_master();
1083        let file = sample_file(&m);
1084        let wrong = MasterSecret::derive("wrong", &[7u8; SALT_LEN]);
1085        // MAC key differs → open fails before any entry decryption.
1086        assert!(matches!(
1087            VaultReader::open(file, &wrong),
1088            Err(VaultError::DecryptionFailed)
1089        ));
1090    }
1091
1092    #[test]
1093    fn v2_salt_mismatch_rejected() {
1094        let m = test_master();
1095        let file = sample_file(&m);
1096        // A master derived under a different salt (stale broker after rekey).
1097        let stale = MasterSecret::derive("test-pass", &[9u8; SALT_LEN]);
1098        assert!(matches!(
1099            VaultReader::open(file, &stale),
1100            Err(VaultError::DecryptionFailed)
1101        ));
1102    }
1103
1104    #[test]
1105    fn v2_ciphertext_tamper_detected() {
1106        let m = test_master();
1107        let mut file = sample_file(&m);
1108        let n = file.len();
1109        file[n - MAC_LEN - 1] ^= 0xFF; // last ciphertext byte
1110        assert!(VaultReader::open(file, &m).is_err());
1111    }
1112
1113    #[test]
1114    fn v2_record_deletion_detected() {
1115        let m = test_master();
1116        let file = sample_file(&m);
1117        // Forge: drop the middle record by rebuilding without re-MACing.
1118        let r = VaultReader::open(file.clone(), &m).unwrap();
1119        let victim = r.records.iter().find(|r| r.name == "DB_URL").unwrap();
1120        let mut forged = Vec::new();
1121        forged.extend_from_slice(&file[..victim.rec_off]);
1122        forged.extend_from_slice(&file[victim.end()..]);
1123        forged[24..28].copy_from_slice(&2u32.to_be_bytes());
1124        assert!(VaultReader::open(forged, &m).is_err());
1125    }
1126
1127    #[test]
1128    fn v2_transplant_rejected_by_aad() {
1129        // Even with a VALID manifest MAC, a ciphertext moved under another name
1130        // must fail: the AAD binds the name into the AEAD itself. Swap the
1131        // crypto material (nonce+tag+ctlen+ct) of two same-bucket entries,
1132        // then re-MAC with the real key — simulating a buggy code path that
1133        // re-MACs without noticing the transplant.
1134        let m = test_master();
1135        let f2 = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1136        let r2 = VaultReader::open(f2.clone(), &m).unwrap();
1137        let ra = &r2.records[0];
1138        let rb = &r2.records[1];
1139        let mut forged = f2.clone();
1140        forged[ra.nonce_off..ra.end()].copy_from_slice(&f2[rb.nonce_off..rb.end()]);
1141        forged[rb.nonce_off..rb.end()].copy_from_slice(&f2[ra.nonce_off..ra.end()]);
1142        // Re-MAC the forged file (attacker without keys can't — but AAD must
1143        // hold even against a buggy path that re-MACs, so simulate with keys).
1144        let body_end = forged.len() - MAC_LEN;
1145        let mk = m.mac_key();
1146        let mut mac = new_hmac(mk.as_ref());
1147        mac.update(&forged[..body_end]);
1148        let tag = mac.finalize().into_bytes();
1149        forged[body_end..].copy_from_slice(&tag);
1150        let rf = VaultReader::open(forged, &m).unwrap();
1151        assert!(rf.decrypt_one(&m, "AAA").is_err());
1152        assert!(rf.decrypt_one(&m, "BBB").is_err());
1153    }
1154
1155    #[test]
1156    fn v2_reorder_rejected() {
1157        // Unsorted records are a structural error before MAC verification.
1158        let m = test_master();
1159        let f = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1160        let r = VaultReader::open(f.clone(), &m).unwrap();
1161        let (ra, rb) = (&r.records[0], &r.records[1]);
1162        let mut forged = f[..V2_HEADER_LEN].to_vec();
1163        forged.extend_from_slice(&f[rb.rec_off..rb.end()]);
1164        forged.extend_from_slice(&f[ra.rec_off..ra.end()]);
1165        let body_end = forged.len();
1166        let mk = m.mac_key();
1167        let mut mac = new_hmac(mk.as_ref());
1168        mac.update(&forged);
1169        forged.extend_from_slice(&mac.finalize().into_bytes());
1170        let _ = body_end;
1171        assert!(matches!(
1172            VaultReader::open(forged, &m),
1173            Err(VaultError::MalformedData)
1174        ));
1175    }
1176
1177    #[test]
1178    fn v2_truncation_and_bounds() {
1179        let m = test_master();
1180        let file = sample_file(&m);
1181        // Truncations at every interesting boundary fail, never panic.
1182        for cut in [0, 3, V2_HEADER_LEN - 1, V2_HEADER_LEN, V2_HEADER_LEN + 5, file.len() - 1] {
1183            assert!(VaultReader::open(file[..cut].to_vec(), &m).is_err());
1184        }
1185        // Absurd declared ct length must fail bounds, not allocate.
1186        let r = VaultReader::open(file.clone(), &m).unwrap();
1187        let rec = &r.records[0];
1188        let mut forged = file.clone();
1189        let off = rec.tag_off() + TAG_LEN;
1190        forged[off..off + 4].copy_from_slice(&u32::MAX.to_be_bytes());
1191        assert!(VaultReader::open(forged, &m).is_err());
1192    }
1193
1194    #[test]
1195    fn v2_unknown_scheme_and_flags_fail_closed() {
1196        let m = test_master();
1197        let file = sample_file(&m);
1198        let mut s = file.clone();
1199        s[V2_HEADER_LEN] = 0x02; // first record's scheme byte
1200        assert!(matches!(
1201            VaultReader::open(s, &m),
1202            Err(VaultError::UnknownScheme(0x02))
1203        ));
1204        let mut fl = file.clone();
1205        fl[6] = 0x80; // flag bit
1206        assert!(matches!(
1207            VaultReader::open(fl, &m),
1208            Err(VaultError::UnsupportedFlags(_))
1209        ));
1210        let mut kdf = file;
1211        kdf[5] = 0x02;
1212        assert!(matches!(
1213            VaultReader::open(kdf, &m),
1214            Err(VaultError::UnsupportedKdf(0x02))
1215        ));
1216    }
1217
1218    #[test]
1219    fn v2_padding_buckets_hide_length() {
1220        let m = test_master();
1221        // 1-byte and 27-byte values land in the same 32-byte bucket (4-byte
1222        // length prefix + value ≤ 32) → identical ct length on the wire.
1223        let f1 = v2_create(&m, &[("K".into(), zv("a"))]).unwrap();
1224        let f2 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyza"))]).unwrap();
1225        assert_eq!(f1.len(), f2.len());
1226        // 29 bytes crosses into the next bucket.
1227        let f3 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyzabc"))]).unwrap();
1228        assert_eq!(f3.len(), f1.len() + PAD_BLOCK);
1229        // Round-trips exactly (true length recovered, padding stripped).
1230        let r = VaultReader::open(f3, &m).unwrap();
1231        assert_eq!(&*r.decrypt_one(&m, "K").unwrap(), b"abcdefghijklmnopqrstuvwxyzabc");
1232    }
1233
1234    #[test]
1235    fn v2_splice_upsert_delete_without_reading() {
1236        let m = test_master();
1237        let file = sample_file(&m);
1238        let r = VaultReader::open(file, &m).unwrap();
1239        let out = r
1240            .splice(
1241                &m,
1242                &[("NEW_KEY".into(), zv("fresh")), ("API_KEY".into(), zv("rotated"))],
1243                &["DB_URL".to_string()],
1244            )
1245            .unwrap();
1246        let r2 = VaultReader::open(out, &m).unwrap();
1247        assert_eq!(
1248            r2.names().collect::<Vec<_>>(),
1249            vec!["API_KEY", "NEW_KEY", "proj/TOKEN"]
1250        );
1251        assert_eq!(&*r2.decrypt_one(&m, "API_KEY").unwrap(), b"rotated");
1252        assert_eq!(&*r2.decrypt_one(&m, "NEW_KEY").unwrap(), b"fresh");
1253        // Untouched record survived byte-verbatim re-emission.
1254        assert_eq!(&*r2.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1255    }
1256
1257    #[test]
1258    fn v2_duplicate_name_rejected() {
1259        let m = test_master();
1260        let f = v2_create(&m, &[("AAA".into(), zv("x")), ("AAB".into(), zv("x"))]).unwrap();
1261        let r = VaultReader::open(f.clone(), &m).unwrap();
1262        let ra = &r.records[0];
1263        // Duplicate AAA by overwriting AAB's name bytes in place (same length).
1264        let mut forged = f.clone();
1265        let rb = &r.records[1];
1266        forged[rb.rec_off + 3..rb.rec_off + 6].copy_from_slice(b"AAA");
1267        let _ = ra;
1268        assert!(matches!(
1269            VaultReader::open(forged, &m),
1270            Err(VaultError::MalformedData)
1271        ));
1272    }
1273
1274    #[test]
1275    fn v2_storage_name_grammar() {
1276        assert!(is_valid_storage_name("API_KEY"));
1277        assert!(is_valid_storage_name("proj/API_KEY"));
1278        assert!(is_valid_storage_name("my.proj/prod-db-password"));
1279        assert!(!is_valid_storage_name("a/b/c"));
1280        assert!(!is_valid_storage_name("/KEY"));
1281        assert!(!is_valid_storage_name("proj/"));
1282        assert!(!is_valid_storage_name(""));
1283        assert!(!is_valid_storage_name("has.dot")); // dots invalid in bare keys
1284    }
1285
1286    // ── Cryptographic known-answer tests (KATs) ──
1287    //
1288    // Round-trip tests have a blind spot: if a derivation detail (HKDF info
1289    // string, AAD layout, padding, MAC input) drifts, encrypt AND decrypt
1290    // drift together and every round-trip stays green — while every REAL
1291    // vault on disk becomes unreadable. These tests pin the exact bytes.
1292
1293    fn hex(bytes: &[u8]) -> String {
1294        bytes.iter().map(|b| format!("{b:02x}")).collect()
1295    }
1296
1297    /// Full-composition KAT: a vault file committed to the repo (generated
1298    /// 2026-07-24 via the CLI) must decrypt to these exact contents FOREVER.
1299    /// If this test fails, the change breaks every existing vault — do not
1300    /// "fix" the expectations; fix the code (or write a migration).
1301    #[test]
1302    fn golden_file_kat() {
1303        let data = include_bytes!("../tests/golden/golden_v2.qvlt").to_vec();
1304        let salt = v2_salt(&data).unwrap();
1305        let m = MasterSecret::derive("golden-pass-do-not-change", &salt);
1306        let r = VaultReader::open(data, &m).unwrap();
1307        assert_eq!(
1308            r.names().collect::<Vec<_>>(),
1309            vec!["BIGKEY", "GREETING", "MULTILINE", "app1/API_KEY", "app2/API_KEY"]
1310        );
1311        assert_eq!(&*r.decrypt_one(&m, "GREETING").unwrap(), b"hello-world");
1312        assert_eq!(&*r.decrypt_one(&m, "MULTILINE").unwrap(), b"line1\nline2");
1313        assert_eq!(&*r.decrypt_one(&m, "app1/API_KEY").unwrap(), b"value-app1");
1314        assert_eq!(&*r.decrypt_one(&m, "app2/API_KEY").unwrap(), b"value-app2");
1315        assert_eq!(
1316            &*r.decrypt_one(&m, "BIGKEY").unwrap(),
1317            b"0123456789012345678901234567890123456789012345678901234567890123"
1318        );
1319    }
1320
1321    /// Derivation-chain KATs, cross-checked against an INDEPENDENT
1322    /// implementation (Python hashlib/hmac, computed 2026-07-24). Pins the
1323    /// PBKDF2 parameters and every HKDF info string: any accidental change
1324    /// to "qvlt2:entry:"/"qvlt2:manifest"/"qvlt2:registry", the length
1325    /// prefix, or the salt wiring fails here with a precise finger.
1326    #[test]
1327    fn derivation_kats_cross_impl() {
1328        let salt: [u8; SALT_LEN] = core::array::from_fn(|i| i as u8);
1329        let m = MasterSecret::derive("golden-pass", &salt);
1330        assert_eq!(
1331            hex(m.secret.as_ref()),
1332            "15bd048606d475f651612dd37b8dcd2e8e11534c8b689d0a6a44d1b8819eff7d"
1333        );
1334        assert_eq!(
1335            hex(m.entry_key("API_KEY").as_ref()),
1336            "8c92f6013a1f708304488ce1d7237b1389d37c3194d3c05fa9dddc1350d05a5c"
1337        );
1338        assert_eq!(
1339            hex(m.mac_key().as_ref()),
1340            "ae55458d0fb8334b5642ff9db564921ecfd59454e31b8fe33d909e4d71ff0fb9"
1341        );
1342        assert_eq!(
1343            hex(m.registry_key().as_ref()),
1344            "eb12b87f7bb7e45749bc5f998d1be4f34a04ef238ca881ecb4eccdd71d6acf4d"
1345        );
1346    }
1347
1348    /// RFC 5869 Appendix A.1 (HKDF-SHA256, basic case) — anchors our use of
1349    /// the `hkdf` crate to the standard's own test vector.
1350    #[test]
1351    fn hkdf_rfc5869_a1() {
1352        let ikm = [0x0bu8; 22];
1353        let salt: Vec<u8> = (0x00..=0x0c).collect();
1354        let info: Vec<u8> = (0xf0..=0xf9).collect();
1355        let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
1356        let mut okm = [0u8; 42];
1357        hk.expand(&info, &mut okm).unwrap();
1358        assert_eq!(
1359            hex(&okm),
1360            "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
1361        );
1362    }
1363
1364    /// RFC 4231 test case 1 (HMAC-SHA256) — anchors the manifest-MAC
1365    /// primitive to the standard's own test vector.
1366    #[test]
1367    fn hmac_rfc4231_case1() {
1368        let mut mac = new_hmac(&[0x0bu8; 20]);
1369        mac.update(b"Hi There");
1370        assert_eq!(
1371            hex(&mac.finalize().into_bytes()),
1372            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1373        );
1374    }
1375
1376    /// Deterministic mutation smoke-fuzz: thousands of corrupted / truncated /
1377    /// garbage variants of a valid file must never panic the parser — every
1378    /// outcome is Ok or a clean Err. (The real coverage-guided harness lives
1379    /// in fuzz/; this keeps a panic-safety floor in plain `cargo test`.)
1380    #[test]
1381    fn mutation_smoke_no_panic() {
1382        let m = test_master();
1383        let base = sample_file(&m);
1384        // xorshift64* — deterministic, no external RNG dep.
1385        let mut s: u64 = 0x9E3779B97F4A7C15;
1386        let mut rng = move || {
1387            s ^= s >> 12;
1388            s ^= s << 25;
1389            s ^= s >> 27;
1390            s = s.wrapping_mul(0x2545F4914F6CDD1D);
1391            s
1392        };
1393        for i in 0..5000u64 {
1394            let mut d = base.clone();
1395            match i % 4 {
1396                0 => {
1397                    // flip 1–3 random bytes
1398                    for _ in 0..=(rng() % 3) {
1399                        let idx = (rng() as usize) % d.len();
1400                        d[idx] ^= (rng() as u8) | 1;
1401                    }
1402                }
1403                1 => {
1404                    // truncate at a random point
1405                    d.truncate((rng() as usize) % (d.len() + 1));
1406                }
1407                2 => {
1408                    // splice random garbage into a random window
1409                    let start = (rng() as usize) % d.len();
1410                    let end = (start + 1 + (rng() as usize) % 64).min(d.len());
1411                    for b in &mut d[start..end] {
1412                        *b = rng() as u8;
1413                    }
1414                }
1415                _ => {
1416                    // pure garbage of random length
1417                    let n = (rng() as usize) % 600;
1418                    d = (0..n).map(|_| rng() as u8).collect();
1419                }
1420            }
1421            if let Ok(r) = VaultReader::open(d, &m) {
1422                for name in r.names().map(String::from).collect::<Vec<_>>() {
1423                    let _ = r.decrypt_one(&m, &name);
1424                }
1425            }
1426        }
1427    }
1428
1429    #[test]
1430    fn raw_key_master_round_trip() {
1431        // The lease path: a v2 container under a raw random key instead of a
1432        // PBKDF2-derived one. Same format, same selective decryption.
1433        let key = [0x5au8; 32];
1434        let salt = [3u8; SALT_LEN];
1435        let m = MasterSecret::from_raw_key(&key, &salt);
1436        let file = v2_create(
1437            &m,
1438            &[("DATABASE_URL".into(), zv("postgres://x")), ("TOKEN".into(), zv("t-1"))],
1439        )
1440        .unwrap();
1441        let r = VaultReader::open(file.clone(), &m).unwrap();
1442        assert_eq!(&*r.decrypt_one(&m, "DATABASE_URL").unwrap(), b"postgres://x");
1443        assert_eq!(&*r.decrypt_one(&m, "TOKEN").unwrap(), b"t-1");
1444
1445        // A different raw key fails the manifest MAC before any decryption.
1446        let other = MasterSecret::from_raw_key(&[0xa5u8; 32], &salt);
1447        assert!(matches!(
1448            VaultReader::open(file, &other),
1449            Err(VaultError::DecryptionFailed)
1450        ));
1451    }
1452
1453    #[test]
1454    fn raw_blob_round_trip_and_tamper() {
1455        let key = [42u8; 32];
1456        let enc = encrypt_raw_blob(b"registry-json", &key).unwrap();
1457        assert_eq!(decrypt_raw_blob(&enc, &key).unwrap(), b"registry-json");
1458        assert!(decrypt_raw_blob(&enc, &[43u8; 32]).is_err());
1459        let mut t = enc.clone();
1460        let n = t.len();
1461        t[n - 1] ^= 1;
1462        assert!(decrypt_raw_blob(&t, &key).is_err());
1463    }
1464
1465    #[test]
1466    fn v1_still_readable() {
1467        // Migration path: v1 files remain decryptable.
1468        let mut vault = Vault::new();
1469        vault.set("OLD", "value");
1470        let v1 = vault.encrypt("pass").unwrap();
1471        assert!(is_v1(&v1));
1472        assert!(!is_v2(&v1));
1473        let back = Vault::decrypt(&v1, "pass").unwrap();
1474        assert_eq!(back.get("OLD"), Some("value"));
1475    }
1476
1477    #[test]
1478    fn parse_env() {
1479        let input = r#"
1480export API_KEY="sk-123"
1481DB_URL=postgres://localhost
1482# comment
1483export EMPTY=
1484
1485BARE=value
1486"#;
1487        let pairs = parse_env_lines(input);
1488        assert_eq!(pairs.len(), 3);
1489        // parse_env_lines returns in file order, not sorted
1490        assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
1491        assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
1492        assert_eq!(pairs[2], ("BARE".into(), "value".into()));
1493    }
1494}