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