1use 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
57const MAGIC: [u8; 4] = *b"QVLT";
60const VERSION: u8 = 0x01;
61pub 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; pub const ITERATIONS: u32 = 600_000;
69
70pub const MAX_KEY_LEN: usize = 256;
72
73pub const MAX_VALUE_LEN: usize = 65536;
75
76#[derive(Debug)]
80pub enum VaultError {
81 TooSmall,
83 BadMagic,
85 UnsupportedVersion(u8),
87 DecryptionFailed,
89 EncryptionFailed,
91 MalformedData,
93 UnsupportedKdf(u8),
95 UnsupportedFlags(u16),
97 UnknownScheme(u8),
99 NotFound,
101 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#[derive(Debug, Clone)]
138pub struct Vault {
139 entries: BTreeMap<String, String>,
140}
141
142impl Vault {
143 pub fn new() -> Self {
145 Self {
146 entries: BTreeMap::new(),
147 }
148 }
149
150 pub fn from_map(entries: BTreeMap<String, String>) -> Self {
152 Self { entries }
153 }
154
155 pub fn get(&self, key: &str) -> Option<&str> {
157 self.entries.get(key).map(|s| s.as_str())
158 }
159
160 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 pub fn delete(&mut self, key: &str) -> Option<String> {
167 self.entries.remove(key)
168 }
169
170 pub fn keys(&self) -> impl Iterator<Item = &str> {
172 self.entries.keys().map(|s| s.as_str())
173 }
174
175 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 pub fn len(&self) -> usize {
182 self.entries.len()
183 }
184
185 pub fn is_empty(&self) -> bool {
187 self.entries.is_empty()
188 }
189
190 pub fn entries_mut(&mut self) -> &mut BTreeMap<String, String> {
192 &mut self.entries
193 }
194
195 pub fn to_map(&self) -> BTreeMap<String, String> {
197 self.entries.clone()
198 }
199
200 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 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 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 buf.zeroize();
268
269 Ok(Self { entries })
270 }
271
272 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 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 for value in self.entries.values_mut() {
313 unsafe {
314 let bytes = value.as_bytes_mut();
315 bytes.zeroize();
316 }
317 }
318 }
319}
320
321pub 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
334pub 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
345pub 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
370pub fn random_bytes(n: usize) -> Vec<u8> {
373 let mut buf = vec![0u8; n];
374 OsRng.fill_bytes(&mut buf);
375 buf
376}
377
378pub fn random_salt() -> [u8; SALT_LEN] {
381 let mut salt = [0u8; SALT_LEN];
382 OsRng.fill_bytes(&mut salt);
383 salt
384}
385
386pub 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
411pub 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
440fn 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
492fn 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
500use hmac::Mac;
505
506fn new_hmac(key: &[u8]) -> HmacSha256 {
509 <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length")
510}
511
512pub const V2_VERSION: u8 = 0x02;
514pub const KDF_PBKDF2: u8 = 0x01;
516pub const SCHEME_HKDF: u8 = 0x01;
518pub const V2_HEADER_LEN: usize = 4 + 1 + 1 + 2 + SALT_LEN + 4; pub const MAC_LEN: usize = 32;
522pub const PAD_BLOCK: usize = 32;
525pub const MAX_NAME_LEN: usize = 513;
527const MAX_PADDED: usize = (4 + MAX_VALUE_LEN).div_ceil(PAD_BLOCK) * PAD_BLOCK;
529
530type HmacSha256 = hmac::Hmac<sha2::Sha256>;
531
532pub 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
540pub struct MasterSecret {
545 secret: Zeroizing<[u8; 32]>,
546 salt: [u8; SALT_LEN],
547}
548
549impl MasterSecret {
550 pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Self {
553 let secret = Zeroizing::new(pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
554 passphrase.as_bytes(),
555 salt,
556 ITERATIONS,
557 ));
558 Self { secret, salt: *salt }
559 }
560
561 pub fn salt(&self) -> &[u8; SALT_LEN] {
562 &self.salt
563 }
564
565 fn hkdf(&self, info: &[u8]) -> Zeroizing<[u8; 32]> {
566 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&self.salt), self.secret.as_ref());
567 let mut okm = Zeroizing::new([0u8; 32]);
568 hk.expand(info, okm.as_mut())
569 .expect("32 bytes is a valid HKDF-SHA256 output length");
570 okm
571 }
572
573 fn entry_key(&self, name: &str) -> Zeroizing<[u8; 32]> {
576 let mut info = Vec::with_capacity(14 + name.len());
577 info.extend_from_slice(b"qvlt2:entry:");
578 info.extend_from_slice(&(name.len() as u16).to_be_bytes());
579 info.extend_from_slice(name.as_bytes());
580 self.hkdf(&info)
581 }
582
583 fn mac_key(&self) -> Zeroizing<[u8; 32]> {
584 self.hkdf(b"qvlt2:manifest")
585 }
586
587 pub fn registry_key(&self) -> Zeroizing<[u8; 32]> {
589 self.hkdf(b"qvlt2:registry")
590 }
591}
592
593pub fn is_v2(data: &[u8]) -> bool {
596 data.len() >= V2_HEADER_LEN && data[0..4] == MAGIC && data[4] == V2_VERSION
597}
598
599pub fn is_v1(data: &[u8]) -> bool {
601 data.len() >= HEADER_LEN && data[0..4] == MAGIC && data[4] == VERSION
602}
603
604pub fn v2_salt(data: &[u8]) -> Result<[u8; SALT_LEN], VaultError> {
608 if data.len() < V2_HEADER_LEN + MAC_LEN {
609 return Err(VaultError::TooSmall);
610 }
611 if data[0..4] != MAGIC {
612 return Err(VaultError::BadMagic);
613 }
614 if data[4] != V2_VERSION {
615 return Err(VaultError::UnsupportedVersion(data[4]));
616 }
617 if data[5] != KDF_PBKDF2 {
618 return Err(VaultError::UnsupportedKdf(data[5]));
619 }
620 let flags = u16::from_be_bytes([data[6], data[7]]);
621 if flags != 0 {
622 return Err(VaultError::UnsupportedFlags(flags));
623 }
624 let mut salt = [0u8; SALT_LEN];
625 salt.copy_from_slice(&data[8..8 + SALT_LEN]);
626 Ok(salt)
627}
628
629struct RecordMeta {
631 scheme: u8,
632 name: String,
633 rec_off: usize,
635 nonce_off: usize,
637 ct_len: usize,
638}
639
640impl RecordMeta {
641 fn tag_off(&self) -> usize {
642 self.nonce_off + NONCE_LEN
643 }
644 fn ct_off(&self) -> usize {
645 self.tag_off() + TAG_LEN + 4
646 }
647 fn end(&self) -> usize {
648 self.ct_off() + self.ct_len
649 }
650}
651
652pub struct VaultReader {
656 data: Vec<u8>,
657 salt: [u8; SALT_LEN],
658 records: Vec<RecordMeta>,
659}
660
661impl VaultReader {
662 pub fn open(data: Vec<u8>, master: &MasterSecret) -> Result<Self, VaultError> {
666 let salt = v2_salt(&data)?;
667 if salt != master.salt {
668 return Err(VaultError::DecryptionFailed);
670 }
671 let count = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as usize;
672 let body_end = data.len() - MAC_LEN;
673
674 let mut records = Vec::new();
675 let mut pos = V2_HEADER_LEN;
676 for _ in 0..count {
677 if pos + 3 > body_end {
680 return Err(VaultError::MalformedData);
681 }
682 let scheme = data[pos];
683 if scheme != SCHEME_HKDF {
684 return Err(VaultError::UnknownScheme(scheme));
687 }
688 let name_len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
689 if name_len == 0 || name_len > MAX_NAME_LEN || pos + 3 + name_len + NONCE_LEN + TAG_LEN + 4 > body_end {
690 return Err(VaultError::MalformedData);
691 }
692 let name = std::str::from_utf8(&data[pos + 3..pos + 3 + name_len])
693 .map_err(|_| VaultError::MalformedData)?
694 .to_string();
695 if !is_valid_storage_name(&name) {
696 return Err(VaultError::MalformedData);
697 }
698 if let Some(prev) = records.last() {
699 let prev: &RecordMeta = prev;
700 if prev.name.as_bytes() >= name.as_bytes() {
701 return Err(VaultError::MalformedData);
703 }
704 }
705 let nonce_off = pos + 3 + name_len;
706 let ct_len_off = nonce_off + NONCE_LEN + TAG_LEN;
707 let ct_len = u32::from_be_bytes([
708 data[ct_len_off],
709 data[ct_len_off + 1],
710 data[ct_len_off + 2],
711 data[ct_len_off + 3],
712 ]) as usize;
713 if ct_len == 0
714 || ct_len % PAD_BLOCK != 0
715 || ct_len > MAX_PADDED
716 || ct_len_off + 4 + ct_len > body_end
717 {
718 return Err(VaultError::MalformedData);
719 }
720 records.push(RecordMeta { scheme, name, rec_off: pos, nonce_off, ct_len });
721 pos = ct_len_off + 4 + ct_len;
722 }
723 if pos != body_end {
724 return Err(VaultError::MalformedData);
726 }
727
728 let mac_key = master.mac_key();
729 let mut mac = new_hmac(mac_key.as_ref());
730 mac.update(&data[..body_end]);
731 mac.verify_slice(&data[body_end..])
733 .map_err(|_| VaultError::DecryptionFailed)?;
734
735 Ok(Self { data, salt, records })
736 }
737
738 pub fn names(&self) -> impl Iterator<Item = &str> {
740 self.records.iter().map(|r| r.name.as_str())
741 }
742
743 pub fn len(&self) -> usize {
744 self.records.len()
745 }
746
747 pub fn is_empty(&self) -> bool {
748 self.records.is_empty()
749 }
750
751 pub fn contains(&self, name: &str) -> bool {
752 self.find(name).is_some()
753 }
754
755 fn find(&self, name: &str) -> Option<&RecordMeta> {
756 self.records
757 .binary_search_by(|r| r.name.as_str().cmp(name))
758 .ok()
759 .map(|i| &self.records[i])
760 }
761
762 pub fn decrypt_one(
765 &self,
766 master: &MasterSecret,
767 name: &str,
768 ) -> Result<Zeroizing<Vec<u8>>, VaultError> {
769 let rec = self.find(name).ok_or(VaultError::NotFound)?;
770 let key = master.entry_key(name);
771 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
772 let nonce = Nonce::from_slice(&self.data[rec.nonce_off..rec.nonce_off + NONCE_LEN]);
773 let tag = Tag::from_slice(&self.data[rec.tag_off()..rec.tag_off() + TAG_LEN]);
774 let aad = record_aad(rec.scheme, name);
775
776 let mut body =
777 Zeroizing::new(self.data[rec.ct_off()..rec.ct_off() + rec.ct_len].to_vec());
778 cipher
779 .decrypt_in_place_detached(nonce, &aad, body.as_mut_slice(), tag)
780 .map_err(|_| VaultError::DecryptionFailed)?;
781
782 let true_len =
783 u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
784 if true_len > MAX_VALUE_LEN || 4 + true_len > body.len() {
785 return Err(VaultError::MalformedData);
786 }
787 Ok(Zeroizing::new(body[4..4 + true_len].to_vec()))
788 }
789
790 pub fn decrypt_all(&self, master: &MasterSecret) -> Result<Vault, VaultError> {
793 let mut entries = BTreeMap::new();
794 for r in &self.records {
795 let v = self.decrypt_one(master, &r.name)?;
796 let s = String::from_utf8_lossy(&v).into_owned();
797 entries.insert(r.name.clone(), s);
798 }
799 Ok(Vault { entries })
800 }
801
802 pub fn splice(
806 &self,
807 master: &MasterSecret,
808 upserts: &[(String, Zeroizing<Vec<u8>>)],
809 deletes: &[String],
810 ) -> Result<Vec<u8>, VaultError> {
811 enum Src<'a> {
812 Keep(&'a RecordMeta),
813 New(&'a str, &'a [u8]),
814 }
815 let mut merged: BTreeMap<&str, Src> = self
816 .records
817 .iter()
818 .map(|r| (r.name.as_str(), Src::Keep(r)))
819 .collect();
820 for name in deletes {
821 merged.remove(name.as_str());
822 }
823 for (name, value) in upserts {
824 if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
825 return Err(VaultError::MalformedData);
826 }
827 merged.insert(&name[..], Src::New(name, value));
828 }
829
830 let mut out = Vec::new();
831 write_v2_header(&mut out, &self.salt, merged.len() as u32)?;
832 for (name, src) in &merged {
833 match src {
834 Src::Keep(rec) => out.extend_from_slice(&self.data[rec.rec_off..rec.end()]),
835 Src::New(name, value) => write_record(&mut out, master, name, value)?,
836 }
837 let _ = name;
838 }
839 append_mac(&mut out, master);
840 Ok(out)
841 }
842}
843
844fn record_aad(scheme: u8, name: &str) -> Vec<u8> {
845 let mut aad = Vec::with_capacity(2 + name.len());
846 aad.push(V2_VERSION);
847 aad.push(scheme);
848 aad.extend_from_slice(name.as_bytes());
849 aad
850}
851
852fn write_v2_header(out: &mut Vec<u8>, salt: &[u8; SALT_LEN], count: u32) -> Result<(), VaultError> {
853 out.write_all(&MAGIC)?;
854 out.write_all(&[V2_VERSION, KDF_PBKDF2, 0, 0])?;
855 out.write_all(salt)?;
856 out.write_all(&count.to_be_bytes())?;
857 Ok(())
858}
859
860fn write_record(
861 out: &mut Vec<u8>,
862 master: &MasterSecret,
863 name: &str,
864 value: &[u8],
865) -> Result<(), VaultError> {
866 let padded = (4 + value.len()).div_ceil(PAD_BLOCK) * PAD_BLOCK;
868 let mut body = Zeroizing::new(vec![0u8; padded]);
869 body[..4].copy_from_slice(&(value.len() as u32).to_be_bytes());
870 body[4..4 + value.len()].copy_from_slice(value);
871
872 let key = master.entry_key(name);
873 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_ref()));
874 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
875 let aad = record_aad(SCHEME_HKDF, name);
876 let tag = cipher
877 .encrypt_in_place_detached(&nonce, &aad, body.as_mut_slice())
878 .map_err(|_| VaultError::EncryptionFailed)?;
879
880 out.push(SCHEME_HKDF);
881 out.write_all(&(name.len() as u16).to_be_bytes())?;
882 out.write_all(name.as_bytes())?;
883 out.write_all(nonce.as_slice())?;
884 out.write_all(tag.as_slice())?;
885 out.write_all(&(padded as u32).to_be_bytes())?;
886 out.write_all(&body)?;
887 Ok(())
888}
889
890fn append_mac(out: &mut Vec<u8>, master: &MasterSecret) {
891 let mac_key = master.mac_key();
892 let mut mac = new_hmac(mac_key.as_ref());
893 mac.update(out);
894 out.extend_from_slice(&mac.finalize().into_bytes());
895}
896
897pub fn v2_create(
900 master: &MasterSecret,
901 entries: &[(String, Zeroizing<Vec<u8>>)],
902) -> Result<Vec<u8>, VaultError> {
903 let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
904 for (name, value) in entries {
905 if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
906 return Err(VaultError::MalformedData);
907 }
908 sorted.insert(&name[..], value);
909 }
910 let mut out = Vec::new();
911 write_v2_header(&mut out, &master.salt, sorted.len() as u32)?;
912 for (name, value) in &sorted {
913 write_record(&mut out, master, name, value)?;
914 }
915 append_mac(&mut out, master);
916 Ok(out)
917}
918
919const RAW_MAGIC: [u8; 4] = *b"QRG2";
926const RAW_AAD: &[u8] = b"qvlt2:registry";
927
928pub fn encrypt_raw_blob(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
929 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
930 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
931 let mut buf = plaintext.to_vec();
932 let tag = cipher
933 .encrypt_in_place_detached(&nonce, RAW_AAD, &mut buf)
934 .map_err(|_| VaultError::EncryptionFailed)?;
935 let mut out = Vec::with_capacity(4 + NONCE_LEN + TAG_LEN + buf.len());
936 out.write_all(&RAW_MAGIC)?;
937 out.write_all(nonce.as_slice())?;
938 out.write_all(tag.as_slice())?;
939 out.write_all(&buf)?;
940 buf.zeroize();
941 Ok(out)
942}
943
944pub fn decrypt_raw_blob(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
945 if data.len() < 4 + NONCE_LEN + TAG_LEN {
946 return Err(VaultError::TooSmall);
947 }
948 if data[0..4] != RAW_MAGIC {
949 return Err(VaultError::BadMagic);
950 }
951 let nonce = Nonce::from_slice(&data[4..4 + NONCE_LEN]);
952 let tag = Tag::from_slice(&data[4 + NONCE_LEN..4 + NONCE_LEN + TAG_LEN]);
953 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
954 let mut buf = data[4 + NONCE_LEN + TAG_LEN..].to_vec();
955 cipher
956 .decrypt_in_place_detached(nonce, RAW_AAD, &mut buf, tag)
957 .map_err(|_| VaultError::DecryptionFailed)?;
958 Ok(buf)
959}
960
961#[cfg(test)]
964mod tests {
965 use super::*;
966
967 #[test]
968 fn round_trip() {
969 let mut vault = Vault::new();
970 vault.set("API_KEY", "sk-secret-123");
971 vault.set("DB_URL", "postgres://localhost/mydb");
972
973 let encrypted = vault.encrypt("test-pass").unwrap();
974 let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
975
976 assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
977 assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
978 assert_eq!(decrypted.len(), 2);
979 }
980
981 #[test]
982 fn wrong_passphrase() {
983 let vault = Vault::new();
984 let encrypted = vault.encrypt("correct").unwrap();
985 assert!(matches!(
986 Vault::decrypt(&encrypted, "wrong"),
987 Err(VaultError::DecryptionFailed)
988 ));
989 }
990
991 #[test]
992 fn tamper_detection() {
993 let mut vault = Vault::new();
994 vault.set("KEY", "value");
995 let mut encrypted = vault.encrypt("pass").unwrap();
996 if let Some(last) = encrypted.last_mut() {
998 *last ^= 0xFF;
999 }
1000 assert!(Vault::decrypt(&encrypted, "pass").is_err());
1001 }
1002
1003 #[test]
1004 fn fresh_nonce_per_encrypt() {
1005 let vault = Vault::new();
1006 let a = vault.encrypt("pass").unwrap();
1007 let b = vault.encrypt("pass").unwrap();
1008 assert_ne!(a, b);
1010 }
1011
1012 #[test]
1013 fn shell_escaping() {
1014 let mut vault = Vault::new();
1015 vault.set("KEY", "it's a \"test\"");
1016 let exports = vault.to_shell_exports();
1017 assert!(exports.contains("'it'\\''s a \"test\"'"));
1018 }
1019
1020 #[test]
1021 fn valid_keys() {
1022 assert!(is_valid_key("API_KEY"));
1023 assert!(is_valid_key("key123"));
1024 assert!(is_valid_key("prod-db-password")); assert!(is_valid_key("metatron-enterprise-lock"));
1026 assert!(!is_valid_key(""));
1027 assert!(!is_valid_key("has space"));
1028 assert!(!is_valid_key("has.dot")); assert!(!is_valid_key("has/slash"));
1030 }
1031
1032 fn zv(s: &str) -> Zeroizing<Vec<u8>> {
1035 Zeroizing::new(s.as_bytes().to_vec())
1036 }
1037
1038 fn test_master() -> MasterSecret {
1039 MasterSecret::derive("test-pass", &[7u8; SALT_LEN])
1041 }
1042
1043 fn sample_file(master: &MasterSecret) -> Vec<u8> {
1044 v2_create(
1045 master,
1046 &[
1047 ("API_KEY".into(), zv("sk-secret-123")),
1048 ("DB_URL".into(), zv("postgres://localhost/mydb")),
1049 ("proj/TOKEN".into(), zv("t-42")),
1050 ],
1051 )
1052 .unwrap()
1053 }
1054
1055 #[test]
1056 fn v2_round_trip_one() {
1057 let m = test_master();
1058 let file = sample_file(&m);
1059 let r = VaultReader::open(file, &m).unwrap();
1060 assert_eq!(r.len(), 3);
1061 assert_eq!(&*r.decrypt_one(&m, "API_KEY").unwrap(), b"sk-secret-123");
1062 assert_eq!(&*r.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1063 assert!(matches!(r.decrypt_one(&m, "NOPE"), Err(VaultError::NotFound)));
1064 }
1065
1066 #[test]
1067 fn v2_wrong_passphrase() {
1068 let m = test_master();
1069 let file = sample_file(&m);
1070 let wrong = MasterSecret::derive("wrong", &[7u8; SALT_LEN]);
1071 assert!(matches!(
1073 VaultReader::open(file, &wrong),
1074 Err(VaultError::DecryptionFailed)
1075 ));
1076 }
1077
1078 #[test]
1079 fn v2_salt_mismatch_rejected() {
1080 let m = test_master();
1081 let file = sample_file(&m);
1082 let stale = MasterSecret::derive("test-pass", &[9u8; SALT_LEN]);
1084 assert!(matches!(
1085 VaultReader::open(file, &stale),
1086 Err(VaultError::DecryptionFailed)
1087 ));
1088 }
1089
1090 #[test]
1091 fn v2_ciphertext_tamper_detected() {
1092 let m = test_master();
1093 let mut file = sample_file(&m);
1094 let n = file.len();
1095 file[n - MAC_LEN - 1] ^= 0xFF; assert!(VaultReader::open(file, &m).is_err());
1097 }
1098
1099 #[test]
1100 fn v2_record_deletion_detected() {
1101 let m = test_master();
1102 let file = sample_file(&m);
1103 let r = VaultReader::open(file.clone(), &m).unwrap();
1105 let victim = r.records.iter().find(|r| r.name == "DB_URL").unwrap();
1106 let mut forged = Vec::new();
1107 forged.extend_from_slice(&file[..victim.rec_off]);
1108 forged.extend_from_slice(&file[victim.end()..]);
1109 forged[24..28].copy_from_slice(&2u32.to_be_bytes());
1110 assert!(VaultReader::open(forged, &m).is_err());
1111 }
1112
1113 #[test]
1114 fn v2_transplant_rejected_by_aad() {
1115 let m = test_master();
1121 let f2 = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1122 let r2 = VaultReader::open(f2.clone(), &m).unwrap();
1123 let ra = &r2.records[0];
1124 let rb = &r2.records[1];
1125 let mut forged = f2.clone();
1126 forged[ra.nonce_off..ra.end()].copy_from_slice(&f2[rb.nonce_off..rb.end()]);
1127 forged[rb.nonce_off..rb.end()].copy_from_slice(&f2[ra.nonce_off..ra.end()]);
1128 let body_end = forged.len() - MAC_LEN;
1131 let mk = m.mac_key();
1132 let mut mac = new_hmac(mk.as_ref());
1133 mac.update(&forged[..body_end]);
1134 let tag = mac.finalize().into_bytes();
1135 forged[body_end..].copy_from_slice(&tag);
1136 let rf = VaultReader::open(forged, &m).unwrap();
1137 assert!(rf.decrypt_one(&m, "AAA").is_err());
1138 assert!(rf.decrypt_one(&m, "BBB").is_err());
1139 }
1140
1141 #[test]
1142 fn v2_reorder_rejected() {
1143 let m = test_master();
1145 let f = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
1146 let r = VaultReader::open(f.clone(), &m).unwrap();
1147 let (ra, rb) = (&r.records[0], &r.records[1]);
1148 let mut forged = f[..V2_HEADER_LEN].to_vec();
1149 forged.extend_from_slice(&f[rb.rec_off..rb.end()]);
1150 forged.extend_from_slice(&f[ra.rec_off..ra.end()]);
1151 let body_end = forged.len();
1152 let mk = m.mac_key();
1153 let mut mac = new_hmac(mk.as_ref());
1154 mac.update(&forged);
1155 forged.extend_from_slice(&mac.finalize().into_bytes());
1156 let _ = body_end;
1157 assert!(matches!(
1158 VaultReader::open(forged, &m),
1159 Err(VaultError::MalformedData)
1160 ));
1161 }
1162
1163 #[test]
1164 fn v2_truncation_and_bounds() {
1165 let m = test_master();
1166 let file = sample_file(&m);
1167 for cut in [0, 3, V2_HEADER_LEN - 1, V2_HEADER_LEN, V2_HEADER_LEN + 5, file.len() - 1] {
1169 assert!(VaultReader::open(file[..cut].to_vec(), &m).is_err());
1170 }
1171 let r = VaultReader::open(file.clone(), &m).unwrap();
1173 let rec = &r.records[0];
1174 let mut forged = file.clone();
1175 let off = rec.tag_off() + TAG_LEN;
1176 forged[off..off + 4].copy_from_slice(&u32::MAX.to_be_bytes());
1177 assert!(VaultReader::open(forged, &m).is_err());
1178 }
1179
1180 #[test]
1181 fn v2_unknown_scheme_and_flags_fail_closed() {
1182 let m = test_master();
1183 let file = sample_file(&m);
1184 let mut s = file.clone();
1185 s[V2_HEADER_LEN] = 0x02; assert!(matches!(
1187 VaultReader::open(s, &m),
1188 Err(VaultError::UnknownScheme(0x02))
1189 ));
1190 let mut fl = file.clone();
1191 fl[6] = 0x80; assert!(matches!(
1193 VaultReader::open(fl, &m),
1194 Err(VaultError::UnsupportedFlags(_))
1195 ));
1196 let mut kdf = file;
1197 kdf[5] = 0x02;
1198 assert!(matches!(
1199 VaultReader::open(kdf, &m),
1200 Err(VaultError::UnsupportedKdf(0x02))
1201 ));
1202 }
1203
1204 #[test]
1205 fn v2_padding_buckets_hide_length() {
1206 let m = test_master();
1207 let f1 = v2_create(&m, &[("K".into(), zv("a"))]).unwrap();
1210 let f2 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyza"))]).unwrap();
1211 assert_eq!(f1.len(), f2.len());
1212 let f3 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyzabc"))]).unwrap();
1214 assert_eq!(f3.len(), f1.len() + PAD_BLOCK);
1215 let r = VaultReader::open(f3, &m).unwrap();
1217 assert_eq!(&*r.decrypt_one(&m, "K").unwrap(), b"abcdefghijklmnopqrstuvwxyzabc");
1218 }
1219
1220 #[test]
1221 fn v2_splice_upsert_delete_without_reading() {
1222 let m = test_master();
1223 let file = sample_file(&m);
1224 let r = VaultReader::open(file, &m).unwrap();
1225 let out = r
1226 .splice(
1227 &m,
1228 &[("NEW_KEY".into(), zv("fresh")), ("API_KEY".into(), zv("rotated"))],
1229 &["DB_URL".to_string()],
1230 )
1231 .unwrap();
1232 let r2 = VaultReader::open(out, &m).unwrap();
1233 assert_eq!(
1234 r2.names().collect::<Vec<_>>(),
1235 vec!["API_KEY", "NEW_KEY", "proj/TOKEN"]
1236 );
1237 assert_eq!(&*r2.decrypt_one(&m, "API_KEY").unwrap(), b"rotated");
1238 assert_eq!(&*r2.decrypt_one(&m, "NEW_KEY").unwrap(), b"fresh");
1239 assert_eq!(&*r2.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
1241 }
1242
1243 #[test]
1244 fn v2_duplicate_name_rejected() {
1245 let m = test_master();
1246 let f = v2_create(&m, &[("AAA".into(), zv("x")), ("AAB".into(), zv("x"))]).unwrap();
1247 let r = VaultReader::open(f.clone(), &m).unwrap();
1248 let ra = &r.records[0];
1249 let mut forged = f.clone();
1251 let rb = &r.records[1];
1252 forged[rb.rec_off + 3..rb.rec_off + 6].copy_from_slice(b"AAA");
1253 let _ = ra;
1254 assert!(matches!(
1255 VaultReader::open(forged, &m),
1256 Err(VaultError::MalformedData)
1257 ));
1258 }
1259
1260 #[test]
1261 fn v2_storage_name_grammar() {
1262 assert!(is_valid_storage_name("API_KEY"));
1263 assert!(is_valid_storage_name("proj/API_KEY"));
1264 assert!(is_valid_storage_name("my.proj/prod-db-password"));
1265 assert!(!is_valid_storage_name("a/b/c"));
1266 assert!(!is_valid_storage_name("/KEY"));
1267 assert!(!is_valid_storage_name("proj/"));
1268 assert!(!is_valid_storage_name(""));
1269 assert!(!is_valid_storage_name("has.dot")); }
1271
1272 fn hex(bytes: &[u8]) -> String {
1280 bytes.iter().map(|b| format!("{b:02x}")).collect()
1281 }
1282
1283 #[test]
1288 fn golden_file_kat() {
1289 let data = include_bytes!("../tests/golden/golden_v2.qvlt").to_vec();
1290 let salt = v2_salt(&data).unwrap();
1291 let m = MasterSecret::derive("golden-pass-do-not-change", &salt);
1292 let r = VaultReader::open(data, &m).unwrap();
1293 assert_eq!(
1294 r.names().collect::<Vec<_>>(),
1295 vec!["BIGKEY", "GREETING", "MULTILINE", "app1/API_KEY", "app2/API_KEY"]
1296 );
1297 assert_eq!(&*r.decrypt_one(&m, "GREETING").unwrap(), b"hello-world");
1298 assert_eq!(&*r.decrypt_one(&m, "MULTILINE").unwrap(), b"line1\nline2");
1299 assert_eq!(&*r.decrypt_one(&m, "app1/API_KEY").unwrap(), b"value-app1");
1300 assert_eq!(&*r.decrypt_one(&m, "app2/API_KEY").unwrap(), b"value-app2");
1301 assert_eq!(
1302 &*r.decrypt_one(&m, "BIGKEY").unwrap(),
1303 b"0123456789012345678901234567890123456789012345678901234567890123"
1304 );
1305 }
1306
1307 #[test]
1313 fn derivation_kats_cross_impl() {
1314 let salt: [u8; SALT_LEN] = core::array::from_fn(|i| i as u8);
1315 let m = MasterSecret::derive("golden-pass", &salt);
1316 assert_eq!(
1317 hex(m.secret.as_ref()),
1318 "15bd048606d475f651612dd37b8dcd2e8e11534c8b689d0a6a44d1b8819eff7d"
1319 );
1320 assert_eq!(
1321 hex(m.entry_key("API_KEY").as_ref()),
1322 "8c92f6013a1f708304488ce1d7237b1389d37c3194d3c05fa9dddc1350d05a5c"
1323 );
1324 assert_eq!(
1325 hex(m.mac_key().as_ref()),
1326 "ae55458d0fb8334b5642ff9db564921ecfd59454e31b8fe33d909e4d71ff0fb9"
1327 );
1328 assert_eq!(
1329 hex(m.registry_key().as_ref()),
1330 "eb12b87f7bb7e45749bc5f998d1be4f34a04ef238ca881ecb4eccdd71d6acf4d"
1331 );
1332 }
1333
1334 #[test]
1337 fn hkdf_rfc5869_a1() {
1338 let ikm = [0x0bu8; 22];
1339 let salt: Vec<u8> = (0x00..=0x0c).collect();
1340 let info: Vec<u8> = (0xf0..=0xf9).collect();
1341 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
1342 let mut okm = [0u8; 42];
1343 hk.expand(&info, &mut okm).unwrap();
1344 assert_eq!(
1345 hex(&okm),
1346 "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
1347 );
1348 }
1349
1350 #[test]
1353 fn hmac_rfc4231_case1() {
1354 let mut mac = new_hmac(&[0x0bu8; 20]);
1355 mac.update(b"Hi There");
1356 assert_eq!(
1357 hex(&mac.finalize().into_bytes()),
1358 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1359 );
1360 }
1361
1362 #[test]
1367 fn mutation_smoke_no_panic() {
1368 let m = test_master();
1369 let base = sample_file(&m);
1370 let mut s: u64 = 0x9E3779B97F4A7C15;
1372 let mut rng = move || {
1373 s ^= s >> 12;
1374 s ^= s << 25;
1375 s ^= s >> 27;
1376 s = s.wrapping_mul(0x2545F4914F6CDD1D);
1377 s
1378 };
1379 for i in 0..5000u64 {
1380 let mut d = base.clone();
1381 match i % 4 {
1382 0 => {
1383 for _ in 0..=(rng() % 3) {
1385 let idx = (rng() as usize) % d.len();
1386 d[idx] ^= (rng() as u8) | 1;
1387 }
1388 }
1389 1 => {
1390 d.truncate((rng() as usize) % (d.len() + 1));
1392 }
1393 2 => {
1394 let start = (rng() as usize) % d.len();
1396 let end = (start + 1 + (rng() as usize) % 64).min(d.len());
1397 for b in &mut d[start..end] {
1398 *b = rng() as u8;
1399 }
1400 }
1401 _ => {
1402 let n = (rng() as usize) % 600;
1404 d = (0..n).map(|_| rng() as u8).collect();
1405 }
1406 }
1407 if let Ok(r) = VaultReader::open(d, &m) {
1408 for name in r.names().map(String::from).collect::<Vec<_>>() {
1409 let _ = r.decrypt_one(&m, &name);
1410 }
1411 }
1412 }
1413 }
1414
1415 #[test]
1416 fn raw_blob_round_trip_and_tamper() {
1417 let key = [42u8; 32];
1418 let enc = encrypt_raw_blob(b"registry-json", &key).unwrap();
1419 assert_eq!(decrypt_raw_blob(&enc, &key).unwrap(), b"registry-json");
1420 assert!(decrypt_raw_blob(&enc, &[43u8; 32]).is_err());
1421 let mut t = enc.clone();
1422 let n = t.len();
1423 t[n - 1] ^= 1;
1424 assert!(decrypt_raw_blob(&t, &key).is_err());
1425 }
1426
1427 #[test]
1428 fn v1_still_readable() {
1429 let mut vault = Vault::new();
1431 vault.set("OLD", "value");
1432 let v1 = vault.encrypt("pass").unwrap();
1433 assert!(is_v1(&v1));
1434 assert!(!is_v2(&v1));
1435 let back = Vault::decrypt(&v1, "pass").unwrap();
1436 assert_eq!(back.get("OLD"), Some("value"));
1437 }
1438
1439 #[test]
1440 fn parse_env() {
1441 let input = r#"
1442export API_KEY="sk-123"
1443DB_URL=postgres://localhost
1444# comment
1445export EMPTY=
1446
1447BARE=value
1448"#;
1449 let pairs = parse_env_lines(input);
1450 assert_eq!(pairs.len(), 3);
1451 assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
1453 assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
1454 assert_eq!(pairs[2], ("BARE".into(), "value".into()));
1455 }
1456}