use std::collections::BTreeMap;
use std::io::Write;
use aes_gcm::aead::{AeadInOut, Generate, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce, Tag};
use zeroize::{Zeroize, Zeroizing};
const MAGIC: [u8; 4] = *b"QVLT";
const VERSION: u8 = 0x01;
pub const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN + TAG_LEN;
pub const ITERATIONS: u32 = 600_000;
pub const MAX_KEY_LEN: usize = 256;
pub const MAX_VALUE_LEN: usize = 65536;
#[derive(Debug)]
pub enum VaultError {
TooSmall,
BadMagic,
UnsupportedVersion(u8),
DecryptionFailed,
EncryptionFailed,
MalformedData,
UnsupportedKdf(u8),
UnsupportedFlags(u16),
UnknownScheme(u8),
NotFound,
Io(std::io::Error),
}
impl std::fmt::Display for VaultError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooSmall => write!(f, "vault file too small"),
Self::BadMagic => write!(f, "invalid vault file (bad magic)"),
Self::UnsupportedVersion(v) => write!(f, "unsupported vault version: {v}"),
Self::DecryptionFailed => write!(f, "decryption failed (wrong passphrase?)"),
Self::EncryptionFailed => write!(f, "encryption failed"),
Self::MalformedData => write!(f, "malformed vault data"),
Self::UnsupportedKdf(k) => write!(f, "unsupported KDF id: {k}"),
Self::UnsupportedFlags(fl) => write!(f, "unsupported vault flags: {fl:#06x}"),
Self::UnknownScheme(s) => write!(f, "unknown entry key scheme: {s}"),
Self::NotFound => write!(f, "key not found"),
Self::Io(e) => write!(f, "I/O error: {e}"),
}
}
}
impl std::error::Error for VaultError {}
impl From<std::io::Error> for VaultError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug, Clone)]
pub struct Vault {
entries: BTreeMap<String, String>,
}
impl Vault {
pub fn new() -> Self {
Self {
entries: BTreeMap::new(),
}
}
pub fn from_map(entries: BTreeMap<String, String>) -> Self {
Self { entries }
}
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(|s| s.as_str())
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
self.entries.insert(key.into(), value.into())
}
pub fn delete(&mut self, key: &str) -> Option<String> {
self.entries.remove(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(|s| s.as_str())
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries_mut(&mut self) -> &mut BTreeMap<String, String> {
&mut self.entries
}
pub fn to_map(&self) -> BTreeMap<String, String> {
self.entries.clone()
}
pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>, VaultError> {
let mut plaintext = serialize(&self.entries);
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut salt).expect("OS RNG failure");
let nonce = Nonce::generate();
let key = derive_key(passphrase, &salt);
let cipher = Aes256Gcm::new(&key);
let tag = cipher
.encrypt_inout_detached(&nonce, b"", plaintext.as_mut_slice().into())
.map_err(|_| VaultError::EncryptionFailed)?;
drop(cipher);
let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len());
out.write_all(&MAGIC)?;
out.write_all(&[VERSION])?;
out.write_all(&salt)?;
out.write_all(nonce.as_slice())?;
out.write_all(tag.as_slice())?;
out.write_all(&plaintext)?;
Ok(out)
}
pub fn decrypt(data: &[u8], passphrase: &str) -> Result<Self, VaultError> {
if data.len() < HEADER_LEN {
return Err(VaultError::TooSmall);
}
if data[0..4] != MAGIC {
return Err(VaultError::BadMagic);
}
if data[4] != VERSION {
return Err(VaultError::UnsupportedVersion(data[4]));
}
let salt = &data[5..5 + SALT_LEN];
let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
let key = derive_key(passphrase, salt);
let cipher = Aes256Gcm::new(&key);
let nonce: &Nonce<_> = nonce_bytes.try_into().expect("nonce length is structural");
let tag: &Tag = tag_bytes.try_into().expect("tag length is structural");
let mut buf = ciphertext.to_vec();
cipher
.decrypt_inout_detached(nonce, b"", buf.as_mut_slice().into(), tag)
.map_err(|_| VaultError::DecryptionFailed)?;
let entries = deserialize(&buf);
buf.zeroize();
Ok(Self { entries })
}
pub fn to_shell_exports(&self) -> String {
let mut out = String::new();
for (key, value) in &self.entries {
let escaped = value.replace('\'', "'\\''");
out.push_str(&format!("export {key}='{escaped}'\n"));
}
out
}
pub fn to_json(&self) -> String {
let mut out = String::from("{\n");
let len = self.entries.len();
for (i, (key, value)) in self.entries.iter().enumerate() {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!(" \"{key}\": \"{escaped}\""));
if i + 1 < len {
out.push(',');
}
out.push('\n');
}
out.push_str("}\n");
out
}
}
impl Default for Vault {
fn default() -> Self {
Self::new()
}
}
impl Drop for Vault {
fn drop(&mut self) {
for value in self.entries.values_mut() {
unsafe {
let bytes = value.as_bytes_mut();
bytes.zeroize();
}
}
}
}
pub fn is_valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= MAX_KEY_LEN
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
pub fn is_valid_project(name: &str) -> bool {
!name.is_empty()
&& name.len() <= MAX_KEY_LEN
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
}
pub fn parse_env_lines(input: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let kv = trimmed.strip_prefix("export ").unwrap_or(trimmed);
if let Some((key, value)) = kv.split_once('=') {
let key = key.trim();
let value = value
.trim()
.trim_start_matches(|c| c == '"' || c == '\'')
.trim_end_matches(|c| c == '"' || c == '\'');
if is_valid_key(key) && !value.is_empty() {
pairs.push((key.to_string(), value.to_string()));
}
}
}
pairs
}
pub fn random_bytes(n: usize) -> Vec<u8> {
let mut buf = vec![0u8; n];
getrandom::fill(&mut buf).expect("OS RNG failure");
buf
}
pub fn random_salt() -> [u8; SALT_LEN] {
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut salt).expect("OS RNG failure");
salt
}
pub fn encrypt_blob(plaintext: &[u8], passphrase: &str) -> Result<Vec<u8>, VaultError> {
let mut buf = plaintext.to_vec();
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut salt).expect("OS RNG failure");
let nonce = Nonce::generate();
let key = derive_key(passphrase, &salt);
let cipher = Aes256Gcm::new(&key);
let tag = cipher
.encrypt_inout_detached(&nonce, b"", buf.as_mut_slice().into())
.map_err(|_| VaultError::EncryptionFailed)?;
let mut out = Vec::with_capacity(HEADER_LEN + buf.len());
out.write_all(&MAGIC)?;
out.write_all(&[VERSION])?;
out.write_all(&salt)?;
out.write_all(nonce.as_slice())?;
out.write_all(tag.as_slice())?;
out.write_all(&buf)?;
buf.zeroize();
Ok(out)
}
pub fn decrypt_blob(data: &[u8], passphrase: &str) -> Result<Vec<u8>, VaultError> {
if data.len() < HEADER_LEN {
return Err(VaultError::TooSmall);
}
if data[0..4] != MAGIC {
return Err(VaultError::BadMagic);
}
if data[4] != VERSION {
return Err(VaultError::UnsupportedVersion(data[4]));
}
let salt = &data[5..5 + SALT_LEN];
let nonce_bytes = &data[5 + SALT_LEN..5 + SALT_LEN + NONCE_LEN];
let tag_bytes = &data[5 + SALT_LEN + NONCE_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
let key = derive_key(passphrase, salt);
let cipher = Aes256Gcm::new(&key);
let nonce: &Nonce<_> = nonce_bytes.try_into().expect("nonce length is structural");
let tag: &Tag = tag_bytes.try_into().expect("tag length is structural");
let mut buf = ciphertext.to_vec();
cipher
.decrypt_inout_detached(nonce, b"", buf.as_mut_slice().into(), tag)
.map_err(|_| VaultError::DecryptionFailed)?;
Ok(buf)
}
fn serialize(entries: &BTreeMap<String, String>) -> Vec<u8> {
let mut buf = Vec::new();
for (key, value) in entries {
let klen = key.len();
buf.push((klen >> 8) as u8);
buf.push((klen & 0xFF) as u8);
buf.extend_from_slice(key.as_bytes());
let vlen = value.len();
buf.push((vlen >> 24) as u8);
buf.push(((vlen >> 16) & 0xFF) as u8);
buf.push(((vlen >> 8) & 0xFF) as u8);
buf.push((vlen & 0xFF) as u8);
buf.extend_from_slice(value.as_bytes());
}
buf.extend_from_slice(&[0x00, 0x00]);
buf
}
fn deserialize(data: &[u8]) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new();
let mut pos = 0;
while pos + 2 <= data.len() {
let klen = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
pos += 2;
if klen == 0 {
break;
}
if klen > MAX_KEY_LEN || pos + klen > data.len() {
break;
}
let key = String::from_utf8_lossy(&data[pos..pos + klen]).to_string();
pos += klen;
if pos + 4 > data.len() {
break;
}
let vlen = ((data[pos] as usize) << 24)
| ((data[pos + 1] as usize) << 16)
| ((data[pos + 2] as usize) << 8)
| (data[pos + 3] as usize);
pos += 4;
if vlen > MAX_VALUE_LEN || pos + vlen > data.len() {
break;
}
let value = String::from_utf8_lossy(&data[pos..pos + vlen]).to_string();
pos += vlen;
entries.insert(key, value);
}
entries
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Key<Aes256Gcm> {
let key =
pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(passphrase.as_bytes(), salt, ITERATIONS);
Key::<Aes256Gcm>::try_from(&key[..]).expect("PBKDF2 output is 32 bytes")
}
use hmac::Mac;
fn new_hmac(key: &[u8]) -> HmacSha256 {
<HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length")
}
pub const V2_VERSION: u8 = 0x02;
pub const KDF_PBKDF2: u8 = 0x01;
pub const SCHEME_HKDF: u8 = 0x01;
pub const V2_HEADER_LEN: usize = 4 + 1 + 1 + 2 + SALT_LEN + 4; pub const MAC_LEN: usize = 32;
pub const PAD_BLOCK: usize = 32;
pub const MAX_NAME_LEN: usize = 513;
const MAX_PADDED: usize = (4 + MAX_VALUE_LEN).div_ceil(PAD_BLOCK) * PAD_BLOCK;
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
pub fn is_valid_storage_name(name: &str) -> bool {
match name.split_once('/') {
Some((project, key)) => is_valid_project(project) && is_valid_key(key),
None => is_valid_key(name),
}
}
pub struct MasterSecret {
secret: Zeroizing<[u8; 32]>,
salt: [u8; SALT_LEN],
}
impl MasterSecret {
pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Self {
let secret = Zeroizing::new(pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(
passphrase.as_bytes(),
salt,
ITERATIONS,
));
Self { secret, salt: *salt }
}
pub fn from_raw_key(key: &[u8; 32], salt: &[u8; SALT_LEN]) -> Self {
Self { secret: Zeroizing::new(*key), salt: *salt }
}
pub fn salt(&self) -> &[u8; SALT_LEN] {
&self.salt
}
fn hkdf(&self, info: &[u8]) -> Zeroizing<[u8; 32]> {
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&self.salt), self.secret.as_ref());
let mut okm = Zeroizing::new([0u8; 32]);
hk.expand(info, okm.as_mut())
.expect("32 bytes is a valid HKDF-SHA256 output length");
okm
}
fn entry_key(&self, name: &str) -> Zeroizing<[u8; 32]> {
let mut info = Vec::with_capacity(14 + name.len());
info.extend_from_slice(b"qvlt2:entry:");
info.extend_from_slice(&(name.len() as u16).to_be_bytes());
info.extend_from_slice(name.as_bytes());
self.hkdf(&info)
}
fn mac_key(&self) -> Zeroizing<[u8; 32]> {
self.hkdf(b"qvlt2:manifest")
}
pub fn registry_key(&self) -> Zeroizing<[u8; 32]> {
self.hkdf(b"qvlt2:registry")
}
}
pub fn is_v2(data: &[u8]) -> bool {
data.len() >= V2_HEADER_LEN && data[0..4] == MAGIC && data[4] == V2_VERSION
}
pub fn is_v1(data: &[u8]) -> bool {
data.len() >= HEADER_LEN && data[0..4] == MAGIC && data[4] == VERSION
}
pub fn v2_salt(data: &[u8]) -> Result<[u8; SALT_LEN], VaultError> {
if data.len() < V2_HEADER_LEN + MAC_LEN {
return Err(VaultError::TooSmall);
}
if data[0..4] != MAGIC {
return Err(VaultError::BadMagic);
}
if data[4] != V2_VERSION {
return Err(VaultError::UnsupportedVersion(data[4]));
}
if data[5] != KDF_PBKDF2 {
return Err(VaultError::UnsupportedKdf(data[5]));
}
let flags = u16::from_be_bytes([data[6], data[7]]);
if flags != 0 {
return Err(VaultError::UnsupportedFlags(flags));
}
let mut salt = [0u8; SALT_LEN];
salt.copy_from_slice(&data[8..8 + SALT_LEN]);
Ok(salt)
}
struct RecordMeta {
scheme: u8,
name: String,
rec_off: usize,
nonce_off: usize,
ct_len: usize,
}
impl RecordMeta {
fn tag_off(&self) -> usize {
self.nonce_off + NONCE_LEN
}
fn ct_off(&self) -> usize {
self.tag_off() + TAG_LEN + 4
}
fn end(&self) -> usize {
self.ct_off() + self.ct_len
}
}
pub struct VaultReader {
data: Vec<u8>,
salt: [u8; SALT_LEN],
records: Vec<RecordMeta>,
}
impl VaultReader {
pub fn open(data: Vec<u8>, master: &MasterSecret) -> Result<Self, VaultError> {
let salt = v2_salt(&data)?;
if salt != master.salt {
return Err(VaultError::DecryptionFailed);
}
let count = u32::from_be_bytes([data[24], data[25], data[26], data[27]]) as usize;
let body_end = data.len() - MAC_LEN;
let mut records = Vec::new();
let mut pos = V2_HEADER_LEN;
for _ in 0..count {
if pos + 3 > body_end {
return Err(VaultError::MalformedData);
}
let scheme = data[pos];
if scheme != SCHEME_HKDF {
return Err(VaultError::UnknownScheme(scheme));
}
let name_len = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
if name_len == 0 || name_len > MAX_NAME_LEN || pos + 3 + name_len + NONCE_LEN + TAG_LEN + 4 > body_end {
return Err(VaultError::MalformedData);
}
let name = std::str::from_utf8(&data[pos + 3..pos + 3 + name_len])
.map_err(|_| VaultError::MalformedData)?
.to_string();
if !is_valid_storage_name(&name) {
return Err(VaultError::MalformedData);
}
if let Some(prev) = records.last() {
let prev: &RecordMeta = prev;
if prev.name.as_bytes() >= name.as_bytes() {
return Err(VaultError::MalformedData);
}
}
let nonce_off = pos + 3 + name_len;
let ct_len_off = nonce_off + NONCE_LEN + TAG_LEN;
let ct_len = u32::from_be_bytes([
data[ct_len_off],
data[ct_len_off + 1],
data[ct_len_off + 2],
data[ct_len_off + 3],
]) as usize;
if ct_len == 0
|| ct_len % PAD_BLOCK != 0
|| ct_len > MAX_PADDED
|| ct_len_off + 4 + ct_len > body_end
{
return Err(VaultError::MalformedData);
}
records.push(RecordMeta { scheme, name, rec_off: pos, nonce_off, ct_len });
pos = ct_len_off + 4 + ct_len;
}
if pos != body_end {
return Err(VaultError::MalformedData);
}
let mac_key = master.mac_key();
let mut mac = new_hmac(mac_key.as_ref());
mac.update(&data[..body_end]);
mac.verify_slice(&data[body_end..])
.map_err(|_| VaultError::DecryptionFailed)?;
Ok(Self { data, salt, records })
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.records.iter().map(|r| r.name.as_str())
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn contains(&self, name: &str) -> bool {
self.find(name).is_some()
}
fn find(&self, name: &str) -> Option<&RecordMeta> {
self.records
.binary_search_by(|r| r.name.as_str().cmp(name))
.ok()
.map(|i| &self.records[i])
}
pub fn decrypt_one(
&self,
master: &MasterSecret,
name: &str,
) -> Result<Zeroizing<Vec<u8>>, VaultError> {
let rec = self.find(name).ok_or(VaultError::NotFound)?;
let key = master.entry_key(name);
let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("derived keys are 32 bytes"));
let nonce: &Nonce<_> = (&self.data[rec.nonce_off..rec.nonce_off + NONCE_LEN])
.try_into()
.expect("nonce length is structural");
let tag: &Tag = (&self.data[rec.tag_off()..rec.tag_off() + TAG_LEN])
.try_into()
.expect("tag length is structural");
let aad = record_aad(rec.scheme, name);
let mut body =
Zeroizing::new(self.data[rec.ct_off()..rec.ct_off() + rec.ct_len].to_vec());
cipher
.decrypt_inout_detached(nonce, &aad, (body.as_mut_slice()).into(), tag)
.map_err(|_| VaultError::DecryptionFailed)?;
let true_len =
u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
if true_len > MAX_VALUE_LEN || 4 + true_len > body.len() {
return Err(VaultError::MalformedData);
}
Ok(Zeroizing::new(body[4..4 + true_len].to_vec()))
}
pub fn decrypt_all(&self, master: &MasterSecret) -> Result<Vault, VaultError> {
let mut entries = BTreeMap::new();
for r in &self.records {
let v = self.decrypt_one(master, &r.name)?;
let s = String::from_utf8_lossy(&v).into_owned();
entries.insert(r.name.clone(), s);
}
Ok(Vault { entries })
}
pub fn splice(
&self,
master: &MasterSecret,
upserts: &[(String, Zeroizing<Vec<u8>>)],
deletes: &[String],
) -> Result<Vec<u8>, VaultError> {
enum Src<'a> {
Keep(&'a RecordMeta),
New(&'a str, &'a [u8]),
}
let mut merged: BTreeMap<&str, Src> = self
.records
.iter()
.map(|r| (r.name.as_str(), Src::Keep(r)))
.collect();
for name in deletes {
merged.remove(name.as_str());
}
for (name, value) in upserts {
if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
return Err(VaultError::MalformedData);
}
merged.insert(&name[..], Src::New(name, value));
}
let mut out = Vec::new();
write_v2_header(&mut out, &self.salt, merged.len() as u32)?;
for (name, src) in &merged {
match src {
Src::Keep(rec) => out.extend_from_slice(&self.data[rec.rec_off..rec.end()]),
Src::New(name, value) => write_record(&mut out, master, name, value)?,
}
let _ = name;
}
append_mac(&mut out, master);
Ok(out)
}
}
fn record_aad(scheme: u8, name: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(2 + name.len());
aad.push(V2_VERSION);
aad.push(scheme);
aad.extend_from_slice(name.as_bytes());
aad
}
fn write_v2_header(out: &mut Vec<u8>, salt: &[u8; SALT_LEN], count: u32) -> Result<(), VaultError> {
out.write_all(&MAGIC)?;
out.write_all(&[V2_VERSION, KDF_PBKDF2, 0, 0])?;
out.write_all(salt)?;
out.write_all(&count.to_be_bytes())?;
Ok(())
}
fn write_record(
out: &mut Vec<u8>,
master: &MasterSecret,
name: &str,
value: &[u8],
) -> Result<(), VaultError> {
let padded = (4 + value.len()).div_ceil(PAD_BLOCK) * PAD_BLOCK;
let mut body = Zeroizing::new(vec![0u8; padded]);
body[..4].copy_from_slice(&(value.len() as u32).to_be_bytes());
body[4..4 + value.len()].copy_from_slice(value);
let key = master.entry_key(name);
let cipher = Aes256Gcm::new(key.as_ref().try_into().expect("derived keys are 32 bytes"));
let nonce = Nonce::generate();
let aad = record_aad(SCHEME_HKDF, name);
let tag = cipher
.encrypt_inout_detached(&nonce, &aad, (body.as_mut_slice()).into())
.map_err(|_| VaultError::EncryptionFailed)?;
out.push(SCHEME_HKDF);
out.write_all(&(name.len() as u16).to_be_bytes())?;
out.write_all(name.as_bytes())?;
out.write_all(nonce.as_slice())?;
out.write_all(tag.as_slice())?;
out.write_all(&(padded as u32).to_be_bytes())?;
out.write_all(&body)?;
Ok(())
}
fn append_mac(out: &mut Vec<u8>, master: &MasterSecret) {
let mac_key = master.mac_key();
let mut mac = new_hmac(mac_key.as_ref());
mac.update(out);
out.extend_from_slice(&mac.finalize().into_bytes());
}
pub fn v2_create(
master: &MasterSecret,
entries: &[(String, Zeroizing<Vec<u8>>)],
) -> Result<Vec<u8>, VaultError> {
let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
for (name, value) in entries {
if !is_valid_storage_name(name) || value.len() > MAX_VALUE_LEN || value.is_empty() {
return Err(VaultError::MalformedData);
}
sorted.insert(&name[..], value);
}
let mut out = Vec::new();
write_v2_header(&mut out, &master.salt, sorted.len() as u32)?;
for (name, value) in &sorted {
write_record(&mut out, master, name, value)?;
}
append_mac(&mut out, master);
Ok(out)
}
const RAW_MAGIC: [u8; 4] = *b"QRG2";
const RAW_AAD: &[u8] = b"qvlt2:registry";
pub fn encrypt_raw_blob(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
let cipher = Aes256Gcm::new(key.try_into().expect("derived keys are 32 bytes"));
let nonce = Nonce::generate();
let mut buf = plaintext.to_vec();
let tag = cipher
.encrypt_inout_detached(&nonce, RAW_AAD, buf.as_mut_slice().into())
.map_err(|_| VaultError::EncryptionFailed)?;
let mut out = Vec::with_capacity(4 + NONCE_LEN + TAG_LEN + buf.len());
out.write_all(&RAW_MAGIC)?;
out.write_all(nonce.as_slice())?;
out.write_all(tag.as_slice())?;
out.write_all(&buf)?;
buf.zeroize();
Ok(out)
}
pub fn decrypt_raw_blob(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, VaultError> {
if data.len() < 4 + NONCE_LEN + TAG_LEN {
return Err(VaultError::TooSmall);
}
if data[0..4] != RAW_MAGIC {
return Err(VaultError::BadMagic);
}
let nonce: &Nonce<_> = (&data[4..4 + NONCE_LEN]).try_into().expect("nonce length is structural");
let tag: &Tag = (&data[4 + NONCE_LEN..4 + NONCE_LEN + TAG_LEN]).try_into().expect("tag length is structural");
let cipher = Aes256Gcm::new(key.try_into().expect("derived keys are 32 bytes"));
let mut buf = data[4 + NONCE_LEN + TAG_LEN..].to_vec();
cipher
.decrypt_inout_detached(nonce, RAW_AAD, buf.as_mut_slice().into(), tag)
.map_err(|_| VaultError::DecryptionFailed)?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let mut vault = Vault::new();
vault.set("API_KEY", "sk-secret-123");
vault.set("DB_URL", "postgres://localhost/mydb");
let encrypted = vault.encrypt("test-pass").unwrap();
let decrypted = Vault::decrypt(&encrypted, "test-pass").unwrap();
assert_eq!(decrypted.get("API_KEY"), Some("sk-secret-123"));
assert_eq!(decrypted.get("DB_URL"), Some("postgres://localhost/mydb"));
assert_eq!(decrypted.len(), 2);
}
#[test]
fn wrong_passphrase() {
let vault = Vault::new();
let encrypted = vault.encrypt("correct").unwrap();
assert!(matches!(
Vault::decrypt(&encrypted, "wrong"),
Err(VaultError::DecryptionFailed)
));
}
#[test]
fn tamper_detection() {
let mut vault = Vault::new();
vault.set("KEY", "value");
let mut encrypted = vault.encrypt("pass").unwrap();
if let Some(last) = encrypted.last_mut() {
*last ^= 0xFF;
}
assert!(Vault::decrypt(&encrypted, "pass").is_err());
}
#[test]
fn fresh_nonce_per_encrypt() {
let vault = Vault::new();
let a = vault.encrypt("pass").unwrap();
let b = vault.encrypt("pass").unwrap();
assert_ne!(a, b);
}
#[test]
fn shell_escaping() {
let mut vault = Vault::new();
vault.set("KEY", "it's a \"test\"");
let exports = vault.to_shell_exports();
assert!(exports.contains("'it'\\''s a \"test\"'"));
}
#[test]
fn valid_keys() {
assert!(is_valid_key("API_KEY"));
assert!(is_valid_key("key123"));
assert!(is_valid_key("prod-db-password")); assert!(is_valid_key("metatron-enterprise-lock"));
assert!(!is_valid_key(""));
assert!(!is_valid_key("has space"));
assert!(!is_valid_key("has.dot")); assert!(!is_valid_key("has/slash"));
}
fn zv(s: &str) -> Zeroizing<Vec<u8>> {
Zeroizing::new(s.as_bytes().to_vec())
}
fn test_master() -> MasterSecret {
MasterSecret::derive("test-pass", &[7u8; SALT_LEN])
}
fn sample_file(master: &MasterSecret) -> Vec<u8> {
v2_create(
master,
&[
("API_KEY".into(), zv("sk-secret-123")),
("DB_URL".into(), zv("postgres://localhost/mydb")),
("proj/TOKEN".into(), zv("t-42")),
],
)
.unwrap()
}
#[test]
fn v2_round_trip_one() {
let m = test_master();
let file = sample_file(&m);
let r = VaultReader::open(file, &m).unwrap();
assert_eq!(r.len(), 3);
assert_eq!(&*r.decrypt_one(&m, "API_KEY").unwrap(), b"sk-secret-123");
assert_eq!(&*r.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
assert!(matches!(r.decrypt_one(&m, "NOPE"), Err(VaultError::NotFound)));
}
#[test]
fn v2_wrong_passphrase() {
let m = test_master();
let file = sample_file(&m);
let wrong = MasterSecret::derive("wrong", &[7u8; SALT_LEN]);
assert!(matches!(
VaultReader::open(file, &wrong),
Err(VaultError::DecryptionFailed)
));
}
#[test]
fn v2_salt_mismatch_rejected() {
let m = test_master();
let file = sample_file(&m);
let stale = MasterSecret::derive("test-pass", &[9u8; SALT_LEN]);
assert!(matches!(
VaultReader::open(file, &stale),
Err(VaultError::DecryptionFailed)
));
}
#[test]
fn v2_ciphertext_tamper_detected() {
let m = test_master();
let mut file = sample_file(&m);
let n = file.len();
file[n - MAC_LEN - 1] ^= 0xFF; assert!(VaultReader::open(file, &m).is_err());
}
#[test]
fn v2_record_deletion_detected() {
let m = test_master();
let file = sample_file(&m);
let r = VaultReader::open(file.clone(), &m).unwrap();
let victim = r.records.iter().find(|r| r.name == "DB_URL").unwrap();
let mut forged = Vec::new();
forged.extend_from_slice(&file[..victim.rec_off]);
forged.extend_from_slice(&file[victim.end()..]);
forged[24..28].copy_from_slice(&2u32.to_be_bytes());
assert!(VaultReader::open(forged, &m).is_err());
}
#[test]
fn v2_transplant_rejected_by_aad() {
let m = test_master();
let f2 = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
let r2 = VaultReader::open(f2.clone(), &m).unwrap();
let ra = &r2.records[0];
let rb = &r2.records[1];
let mut forged = f2.clone();
forged[ra.nonce_off..ra.end()].copy_from_slice(&f2[rb.nonce_off..rb.end()]);
forged[rb.nonce_off..rb.end()].copy_from_slice(&f2[ra.nonce_off..ra.end()]);
let body_end = forged.len() - MAC_LEN;
let mk = m.mac_key();
let mut mac = new_hmac(mk.as_ref());
mac.update(&forged[..body_end]);
let tag = mac.finalize().into_bytes();
forged[body_end..].copy_from_slice(&tag);
let rf = VaultReader::open(forged, &m).unwrap();
assert!(rf.decrypt_one(&m, "AAA").is_err());
assert!(rf.decrypt_one(&m, "BBB").is_err());
}
#[test]
fn v2_reorder_rejected() {
let m = test_master();
let f = v2_create(&m, &[("AAA".into(), zv("x")), ("BBB".into(), zv("y"))]).unwrap();
let r = VaultReader::open(f.clone(), &m).unwrap();
let (ra, rb) = (&r.records[0], &r.records[1]);
let mut forged = f[..V2_HEADER_LEN].to_vec();
forged.extend_from_slice(&f[rb.rec_off..rb.end()]);
forged.extend_from_slice(&f[ra.rec_off..ra.end()]);
let body_end = forged.len();
let mk = m.mac_key();
let mut mac = new_hmac(mk.as_ref());
mac.update(&forged);
forged.extend_from_slice(&mac.finalize().into_bytes());
let _ = body_end;
assert!(matches!(
VaultReader::open(forged, &m),
Err(VaultError::MalformedData)
));
}
#[test]
fn v2_truncation_and_bounds() {
let m = test_master();
let file = sample_file(&m);
for cut in [0, 3, V2_HEADER_LEN - 1, V2_HEADER_LEN, V2_HEADER_LEN + 5, file.len() - 1] {
assert!(VaultReader::open(file[..cut].to_vec(), &m).is_err());
}
let r = VaultReader::open(file.clone(), &m).unwrap();
let rec = &r.records[0];
let mut forged = file.clone();
let off = rec.tag_off() + TAG_LEN;
forged[off..off + 4].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(VaultReader::open(forged, &m).is_err());
}
#[test]
fn v2_unknown_scheme_and_flags_fail_closed() {
let m = test_master();
let file = sample_file(&m);
let mut s = file.clone();
s[V2_HEADER_LEN] = 0x02; assert!(matches!(
VaultReader::open(s, &m),
Err(VaultError::UnknownScheme(0x02))
));
let mut fl = file.clone();
fl[6] = 0x80; assert!(matches!(
VaultReader::open(fl, &m),
Err(VaultError::UnsupportedFlags(_))
));
let mut kdf = file;
kdf[5] = 0x02;
assert!(matches!(
VaultReader::open(kdf, &m),
Err(VaultError::UnsupportedKdf(0x02))
));
}
#[test]
fn v2_padding_buckets_hide_length() {
let m = test_master();
let f1 = v2_create(&m, &[("K".into(), zv("a"))]).unwrap();
let f2 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyza"))]).unwrap();
assert_eq!(f1.len(), f2.len());
let f3 = v2_create(&m, &[("K".into(), zv("abcdefghijklmnopqrstuvwxyzabc"))]).unwrap();
assert_eq!(f3.len(), f1.len() + PAD_BLOCK);
let r = VaultReader::open(f3, &m).unwrap();
assert_eq!(&*r.decrypt_one(&m, "K").unwrap(), b"abcdefghijklmnopqrstuvwxyzabc");
}
#[test]
fn v2_splice_upsert_delete_without_reading() {
let m = test_master();
let file = sample_file(&m);
let r = VaultReader::open(file, &m).unwrap();
let out = r
.splice(
&m,
&[("NEW_KEY".into(), zv("fresh")), ("API_KEY".into(), zv("rotated"))],
&["DB_URL".to_string()],
)
.unwrap();
let r2 = VaultReader::open(out, &m).unwrap();
assert_eq!(
r2.names().collect::<Vec<_>>(),
vec!["API_KEY", "NEW_KEY", "proj/TOKEN"]
);
assert_eq!(&*r2.decrypt_one(&m, "API_KEY").unwrap(), b"rotated");
assert_eq!(&*r2.decrypt_one(&m, "NEW_KEY").unwrap(), b"fresh");
assert_eq!(&*r2.decrypt_one(&m, "proj/TOKEN").unwrap(), b"t-42");
}
#[test]
fn v2_duplicate_name_rejected() {
let m = test_master();
let f = v2_create(&m, &[("AAA".into(), zv("x")), ("AAB".into(), zv("x"))]).unwrap();
let r = VaultReader::open(f.clone(), &m).unwrap();
let ra = &r.records[0];
let mut forged = f.clone();
let rb = &r.records[1];
forged[rb.rec_off + 3..rb.rec_off + 6].copy_from_slice(b"AAA");
let _ = ra;
assert!(matches!(
VaultReader::open(forged, &m),
Err(VaultError::MalformedData)
));
}
#[test]
fn v2_storage_name_grammar() {
assert!(is_valid_storage_name("API_KEY"));
assert!(is_valid_storage_name("proj/API_KEY"));
assert!(is_valid_storage_name("my.proj/prod-db-password"));
assert!(!is_valid_storage_name("a/b/c"));
assert!(!is_valid_storage_name("/KEY"));
assert!(!is_valid_storage_name("proj/"));
assert!(!is_valid_storage_name(""));
assert!(!is_valid_storage_name("has.dot")); }
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn golden_file_kat() {
let data = include_bytes!("../tests/golden/golden_v2.qvlt").to_vec();
let salt = v2_salt(&data).unwrap();
let m = MasterSecret::derive("golden-pass-do-not-change", &salt);
let r = VaultReader::open(data, &m).unwrap();
assert_eq!(
r.names().collect::<Vec<_>>(),
vec!["BIGKEY", "GREETING", "MULTILINE", "app1/API_KEY", "app2/API_KEY"]
);
assert_eq!(&*r.decrypt_one(&m, "GREETING").unwrap(), b"hello-world");
assert_eq!(&*r.decrypt_one(&m, "MULTILINE").unwrap(), b"line1\nline2");
assert_eq!(&*r.decrypt_one(&m, "app1/API_KEY").unwrap(), b"value-app1");
assert_eq!(&*r.decrypt_one(&m, "app2/API_KEY").unwrap(), b"value-app2");
assert_eq!(
&*r.decrypt_one(&m, "BIGKEY").unwrap(),
b"0123456789012345678901234567890123456789012345678901234567890123"
);
}
#[test]
fn derivation_kats_cross_impl() {
let salt: [u8; SALT_LEN] = core::array::from_fn(|i| i as u8);
let m = MasterSecret::derive("golden-pass", &salt);
assert_eq!(
hex(m.secret.as_ref()),
"15bd048606d475f651612dd37b8dcd2e8e11534c8b689d0a6a44d1b8819eff7d"
);
assert_eq!(
hex(m.entry_key("API_KEY").as_ref()),
"8c92f6013a1f708304488ce1d7237b1389d37c3194d3c05fa9dddc1350d05a5c"
);
assert_eq!(
hex(m.mac_key().as_ref()),
"ae55458d0fb8334b5642ff9db564921ecfd59454e31b8fe33d909e4d71ff0fb9"
);
assert_eq!(
hex(m.registry_key().as_ref()),
"eb12b87f7bb7e45749bc5f998d1be4f34a04ef238ca881ecb4eccdd71d6acf4d"
);
}
#[test]
fn hkdf_rfc5869_a1() {
let ikm = [0x0bu8; 22];
let salt: Vec<u8> = (0x00..=0x0c).collect();
let info: Vec<u8> = (0xf0..=0xf9).collect();
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(&salt), &ikm);
let mut okm = [0u8; 42];
hk.expand(&info, &mut okm).unwrap();
assert_eq!(
hex(&okm),
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
);
}
#[test]
fn hmac_rfc4231_case1() {
let mut mac = new_hmac(&[0x0bu8; 20]);
mac.update(b"Hi There");
assert_eq!(
hex(&mac.finalize().into_bytes()),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
);
}
#[test]
fn mutation_smoke_no_panic() {
let m = test_master();
let base = sample_file(&m);
let mut s: u64 = 0x9E3779B97F4A7C15;
let mut rng = move || {
s ^= s >> 12;
s ^= s << 25;
s ^= s >> 27;
s = s.wrapping_mul(0x2545F4914F6CDD1D);
s
};
for i in 0..5000u64 {
let mut d = base.clone();
match i % 4 {
0 => {
for _ in 0..=(rng() % 3) {
let idx = (rng() as usize) % d.len();
d[idx] ^= (rng() as u8) | 1;
}
}
1 => {
d.truncate((rng() as usize) % (d.len() + 1));
}
2 => {
let start = (rng() as usize) % d.len();
let end = (start + 1 + (rng() as usize) % 64).min(d.len());
for b in &mut d[start..end] {
*b = rng() as u8;
}
}
_ => {
let n = (rng() as usize) % 600;
d = (0..n).map(|_| rng() as u8).collect();
}
}
if let Ok(r) = VaultReader::open(d, &m) {
for name in r.names().map(String::from).collect::<Vec<_>>() {
let _ = r.decrypt_one(&m, &name);
}
}
}
}
#[test]
fn raw_key_master_round_trip() {
let key = [0x5au8; 32];
let salt = [3u8; SALT_LEN];
let m = MasterSecret::from_raw_key(&key, &salt);
let file = v2_create(
&m,
&[("DATABASE_URL".into(), zv("postgres://x")), ("TOKEN".into(), zv("t-1"))],
)
.unwrap();
let r = VaultReader::open(file.clone(), &m).unwrap();
assert_eq!(&*r.decrypt_one(&m, "DATABASE_URL").unwrap(), b"postgres://x");
assert_eq!(&*r.decrypt_one(&m, "TOKEN").unwrap(), b"t-1");
let other = MasterSecret::from_raw_key(&[0xa5u8; 32], &salt);
assert!(matches!(
VaultReader::open(file, &other),
Err(VaultError::DecryptionFailed)
));
}
#[test]
fn raw_blob_round_trip_and_tamper() {
let key = [42u8; 32];
let enc = encrypt_raw_blob(b"registry-json", &key).unwrap();
assert_eq!(decrypt_raw_blob(&enc, &key).unwrap(), b"registry-json");
assert!(decrypt_raw_blob(&enc, &[43u8; 32]).is_err());
let mut t = enc.clone();
let n = t.len();
t[n - 1] ^= 1;
assert!(decrypt_raw_blob(&t, &key).is_err());
}
#[test]
fn v1_still_readable() {
let mut vault = Vault::new();
vault.set("OLD", "value");
let v1 = vault.encrypt("pass").unwrap();
assert!(is_v1(&v1));
assert!(!is_v2(&v1));
let back = Vault::decrypt(&v1, "pass").unwrap();
assert_eq!(back.get("OLD"), Some("value"));
}
#[test]
fn parse_env() {
let input = r#"
export API_KEY="sk-123"
DB_URL=postgres://localhost
# comment
export EMPTY=
BARE=value
"#;
let pairs = parse_env_lines(input);
assert_eq!(pairs.len(), 3);
assert_eq!(pairs[0], ("API_KEY".into(), "sk-123".into()));
assert_eq!(pairs[1], ("DB_URL".into(), "postgres://localhost".into()));
assert_eq!(pairs[2], ("BARE".into(), "value".into()));
}
}