use std::fs::{self, File, OpenOptions};
use std::io::Read as IoRead;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use argon2::{Algorithm, Argon2, Params, Version};
use ring::aead::{
Aad, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey, AES_256_GCM, NONCE_LEN,
};
use ring::hmac;
use ring::rand::{SecureRandom, SystemRandom};
use zeroize::{Zeroize, Zeroizing};
use crate::error::*;
use crate::harden::{madvise_free, mlock_region, munlock_region};
const MAGIC: &[u8; 8] = b"SECRETSH";
const VERSION: u8 = 1;
const CIPHER_ID: u8 = 0x01;
const HEADER_COVERED_LEN: usize = 8 + 1 + 1 + 12 + 16 + 4 + 32;
const HEADER_TOTAL_LEN: usize = HEADER_COVERED_LEN + 32;
const KDF_SALT_LEN: usize = 16;
const HMAC_LEN: usize = 32;
const GCM_TAG_LEN: usize = 16;
const GCM_NONCE_LEN: usize = NONCE_LEN;
const MAX_ENTRIES: usize = 10_000;
const MIN_PASSPHRASE_LEN: usize = 12;
const DEFAULT_M_COST: u32 = 131_072; const DEFAULT_T_COST: u32 = 3;
const DEFAULT_P_COST: u32 = 4;
const HKDF_ENC_INFO: &[u8] = b"secretsh-enc-v1";
const HKDF_MAC_INFO: &[u8] = b"secretsh-mac-v1";
const LOCK_TIMEOUT_SECS: u64 = 30;
const STALE_LOCK_AGE_SECS: u64 = 300;
#[cfg(unix)]
const O_CLOEXEC: libc::c_int = libc::O_CLOEXEC;
fn validate_key_name(name: &str) -> Result<(), SecretshError> {
if name.is_empty() {
return Err(SecretshError::Vault(VaultError::NotFound {
path: PathBuf::from(name),
}));
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return Err(SecretshError::Tokenization(
TokenizationError::RejectedMetacharacter {
character: first,
offset: 0,
},
));
}
for (i, c) in chars.enumerate() {
if !c.is_ascii_alphanumeric() && c != '_' {
return Err(SecretshError::Tokenization(
TokenizationError::RejectedMetacharacter {
character: c,
offset: i + 1,
},
));
}
}
Ok(())
}
struct OneShotNonce(Option<[u8; GCM_NONCE_LEN]>);
impl NonceSequence for OneShotNonce {
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
self.0
.take()
.map(Nonce::assume_unique_for_key)
.ok_or(ring::error::Unspecified)
}
}
struct DerivedKeys {
enc_key: Zeroizing<Vec<u8>>,
mac_key: Zeroizing<Vec<u8>>,
}
impl Drop for DerivedKeys {
fn drop(&mut self) {
self.enc_key.zeroize();
self.mac_key.zeroize();
}
}
#[derive(Clone, Copy, Debug)]
struct KdfParams {
m_cost: u32,
t_cost: u32,
p_cost: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self {
m_cost: DEFAULT_M_COST,
t_cost: DEFAULT_T_COST,
p_cost: DEFAULT_P_COST,
}
}
}
struct Entry {
key: String,
value: Zeroizing<Vec<u8>>,
}
impl Drop for Entry {
fn drop(&mut self) {
self.value.zeroize();
}
}
pub struct VaultConfig {
pub vault_path: PathBuf,
pub master_key_env: String,
pub allow_insecure_permissions: bool,
pub kdf_memory: Option<u32>,
}
pub struct Vault {
vault_path: PathBuf,
master_key_env: String,
#[allow(dead_code)]
allow_insecure_permissions: bool,
kdf_params: KdfParams,
entries: Vec<Entry>,
locked_regions: Vec<(usize, usize)>,
}
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("vault_path", &self.vault_path)
.field("entry_count", &self.entries.len())
.finish_non_exhaustive()
}
}
impl Drop for Vault {
fn drop(&mut self) {
self.close();
}
}
fn read_passphrase(env_var: &str) -> Result<Zeroizing<Vec<u8>>, SecretshError> {
let val = std::env::var(env_var).map_err(|_| {
SecretshError::MasterKey(MasterKeyError::EnvVarNotSet {
env_var: env_var.to_owned(),
})
})?;
Ok(Zeroizing::new(val.into_bytes()))
}
fn derive_keys(
passphrase: &[u8],
salt: &[u8; KDF_SALT_LEN],
params: &KdfParams,
) -> Result<DerivedKeys, SecretshError> {
let argon2_params = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32))
.map_err(|e| {
SecretshError::Io(IoError(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("argon2 params error: {e}"),
)))
})?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut ikm = Zeroizing::new(vec![0u8; 32]);
argon2
.hash_password_into(passphrase, salt, ikm.as_mut_slice())
.map_err(|e| {
SecretshError::Io(IoError(std::io::Error::other(format!(
"argon2 hash error: {e}"
))))
})?;
let salt_hkdf = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, &[]);
let prk = salt_hkdf.extract(ikm.as_slice());
let mut enc_key = Zeroizing::new(vec![0u8; 32]);
let mut mac_key = Zeroizing::new(vec![0u8; 32]);
prk.expand(&[HKDF_ENC_INFO], MyLen(32))
.and_then(|okm| okm.fill(enc_key.as_mut_slice()))
.map_err(|_| {
SecretshError::Io(IoError(std::io::Error::other("HKDF expand (enc) failed")))
})?;
prk.expand(&[HKDF_MAC_INFO], MyLen(32))
.and_then(|okm| okm.fill(mac_key.as_mut_slice()))
.map_err(|_| {
SecretshError::Io(IoError(std::io::Error::other("HKDF expand (mac) failed")))
})?;
ikm.zeroize();
Ok(DerivedKeys { enc_key, mac_key })
}
struct MyLen(usize);
impl ring::hkdf::KeyType for MyLen {
fn len(&self) -> usize {
self.0
}
}
fn hmac_sign(key_bytes: &[u8], data: &[u8]) -> [u8; HMAC_LEN] {
let key = hmac::Key::new(hmac::HMAC_SHA256, key_bytes);
let tag = hmac::sign(&key, data);
let mut out = [0u8; HMAC_LEN];
out.copy_from_slice(tag.as_ref());
out
}
fn hmac_verify(key_bytes: &[u8], data: &[u8], expected: &[u8; HMAC_LEN]) -> bool {
let key = hmac::Key::new(hmac::HMAC_SHA256, key_bytes);
hmac::verify(&key, data, expected).is_ok()
}
fn aes_gcm_seal(
enc_key: &[u8],
plaintext: &[u8],
aad: &[u8],
rng: &SystemRandom,
) -> Result<(Vec<u8>, Vec<u8>), SecretshError> {
let mut nonce_bytes = [0u8; GCM_NONCE_LEN];
rng.fill(&mut nonce_bytes)
.map_err(|_| SecretshError::Io(IoError(std::io::Error::other("RNG fill failed"))))?;
let unbound = UnboundKey::new(&AES_256_GCM, enc_key).map_err(|_| {
SecretshError::Io(IoError(std::io::Error::other(
"AES-256-GCM key construction failed",
)))
})?;
let mut sealing_key = SealingKey::new(unbound, OneShotNonce(Some(nonce_bytes)));
let mut in_out = plaintext.to_vec();
in_out.extend_from_slice(&[0u8; GCM_TAG_LEN]);
sealing_key
.seal_in_place_separate_tag(Aad::from(aad), &mut in_out[..plaintext.len()])
.map(|tag| {
in_out.truncate(plaintext.len());
in_out.extend_from_slice(tag.as_ref());
})
.map_err(|_| {
SecretshError::Io(IoError(std::io::Error::other("AES-256-GCM seal failed")))
})?;
Ok((nonce_bytes.to_vec(), in_out))
}
fn aes_gcm_open(
enc_key: &[u8],
nonce_bytes: &[u8; GCM_NONCE_LEN],
ciphertext_with_tag: &[u8],
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>, ()> {
let unbound = UnboundKey::new(&AES_256_GCM, enc_key).map_err(|_| ())?;
let mut opening_key = OpeningKey::new(unbound, OneShotNonce(Some(*nonce_bytes)));
let mut in_out = ciphertext_with_tag.to_vec();
let plaintext_len = opening_key
.open_in_place(Aad::from(aad), &mut in_out)
.map_err(|_| ())?
.len();
in_out.truncate(plaintext_len);
Ok(Zeroizing::new(in_out))
}
fn check_permissions(path: &Path, allow_insecure: bool) -> Result<(), SecretshError> {
let meta = fs::metadata(path).map_err(|e| SecretshError::Io(IoError(e)))?;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
if allow_insecure {
return Ok(());
}
return Err(SecretshError::Vault(VaultError::InsecurePermissions {
path: path.to_owned(),
mode,
}));
}
Ok(())
}
struct LockGuard {
lock_path: PathBuf,
lock_file: Option<File>,
}
impl Drop for LockGuard {
fn drop(&mut self) {
if let Some(ref f) = self.lock_file {
unsafe {
libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(f), libc::LOCK_UN);
}
}
if let Ok(f) = OpenOptions::new()
.write(true)
.custom_flags(O_CLOEXEC)
.open(&self.lock_path)
{
let _ = f.set_len(0);
}
}
}
fn acquire_lock(vault_path: &Path) -> Result<LockGuard, SecretshError> {
let lock_path = lock_path_for(vault_path);
let start = Instant::now();
let timeout = Duration::from_secs(LOCK_TIMEOUT_SECS);
let mut backoff = Duration::from_millis(10);
loop {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(O_CLOEXEC)
.open(&lock_path)
.map_err(|e| SecretshError::Io(IoError(e)))?;
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if ret == 0 {
let pid = std::process::id();
let ts = chrono::Utc::now().to_rfc3339();
let content = format!("{pid}\n{ts}\n");
let mut f = &file;
let _ = f.write_all(content.as_bytes());
let _ = f.flush();
return Ok(LockGuard {
lock_path,
lock_file: Some(file),
});
}
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(SecretshError::Vault(VaultError::LockTimeout {
lockfile_path: lock_path,
elapsed_secs: elapsed.as_secs(),
}));
}
if let Ok(content) = fs::read_to_string(&lock_path) {
let mut lines = content.lines();
if let (Some(pid_str), Some(ts_str)) = (lines.next(), lines.next()) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
let pid_alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
let lock_age_secs = chrono::DateTime::parse_from_rfc3339(ts_str.trim())
.ok()
.map(|t| {
let now = chrono::Utc::now();
(now - t.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64
})
.unwrap_or(0);
if !pid_alive || lock_age_secs > STALE_LOCK_AGE_SECS {
let _ = fs::remove_file(&lock_path);
eprintln!(
"secretsh: stale vault lockfile removed (pid={pid}, age={lock_age_secs}s)"
);
continue;
}
}
}
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(Duration::from_secs(1));
}
}
fn lock_path_for(vault_path: &Path) -> PathBuf {
let mut p = vault_path.to_owned();
let name = p
.file_name()
.map(|n| format!("{}.lock", n.to_string_lossy()))
.unwrap_or_else(|| "vault.lock".to_owned());
p.set_file_name(name);
p
}
fn tmp_path_for(vault_path: &Path) -> PathBuf {
let pid = std::process::id();
let mut p = vault_path.to_owned();
let name = p
.file_name()
.map(|n| format!("{}.tmp.{pid}", n.to_string_lossy()))
.unwrap_or_else(|| format!("vault.tmp.{pid}"));
p.set_file_name(name);
p
}
fn build_header_covered(
kdf_params: &KdfParams,
kdf_salt: &[u8; KDF_SALT_LEN],
entry_count: u32,
) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_COVERED_LEN);
buf.extend_from_slice(MAGIC);
buf.push(VERSION);
buf.push(CIPHER_ID);
buf.extend_from_slice(&kdf_params.m_cost.to_le_bytes());
buf.extend_from_slice(&kdf_params.t_cost.to_le_bytes());
buf.extend_from_slice(&kdf_params.p_cost.to_le_bytes());
buf.extend_from_slice(kdf_salt);
buf.extend_from_slice(&entry_count.to_le_bytes());
buf.extend_from_slice(&[0u8; 32]); debug_assert_eq!(buf.len(), HEADER_COVERED_LEN);
buf
}
fn serialize_entries(
entries: &[Entry],
enc_key: &[u8],
rng: &SystemRandom,
) -> Result<Vec<u8>, SecretshError> {
let mut buf = Vec::new();
for (idx, entry) in entries.iter().enumerate() {
let key_bytes = entry.key.as_bytes();
let key_len = key_bytes.len() as u16;
let mut plaintext = Vec::with_capacity(2 + key_bytes.len() + entry.value.len());
plaintext.extend_from_slice(&key_len.to_le_bytes());
plaintext.extend_from_slice(key_bytes);
plaintext.extend_from_slice(&entry.value);
let aad = (idx as u32).to_be_bytes();
let (nonce, ciphertext) = aes_gcm_seal(enc_key, &plaintext, &aad, rng)?;
plaintext.zeroize();
let ct_len = ciphertext.len() as u32;
buf.extend_from_slice(&nonce);
buf.extend_from_slice(&ct_len.to_le_bytes());
buf.extend_from_slice(&ciphertext);
}
Ok(buf)
}
impl Vault {
pub fn init(config: &VaultConfig) -> Result<(), SecretshError> {
Self::init_inner(config, false)
}
pub fn init_no_passphrase_check(config: &VaultConfig) -> Result<(), SecretshError> {
Self::init_inner(config, true)
}
fn init_inner(config: &VaultConfig, no_passphrase_check: bool) -> Result<(), SecretshError> {
let passphrase = read_passphrase(&config.master_key_env)?;
if !no_passphrase_check && passphrase.len() < MIN_PASSPHRASE_LEN {
return Err(SecretshError::MasterKey(
MasterKeyError::PassphraseTooShort {
length: passphrase.len(),
minimum: MIN_PASSPHRASE_LEN,
},
));
}
if let Some(parent) = config.vault_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
fs::create_dir_all(parent).map_err(|e| SecretshError::Io(IoError(e)))?;
#[cfg(unix)]
{
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|e| SecretshError::Io(IoError(e)))?;
}
}
}
let _lock = acquire_lock(&config.vault_path)?;
let kdf_params = KdfParams {
m_cost: config.kdf_memory.unwrap_or(DEFAULT_M_COST),
t_cost: DEFAULT_T_COST,
p_cost: DEFAULT_P_COST,
};
let rng = SystemRandom::new();
let mut kdf_salt = [0u8; KDF_SALT_LEN];
rng.fill(&mut kdf_salt)
.map_err(|_| SecretshError::Io(IoError(std::io::Error::other("RNG fill failed"))))?;
let keys = derive_keys(&passphrase, &kdf_salt, &kdf_params)?;
let header_covered = build_header_covered(&kdf_params, &kdf_salt, 0);
let header_hmac = hmac_sign(&keys.mac_key, &header_covered);
let mut vault_bytes = Vec::new();
vault_bytes.extend_from_slice(&header_covered);
vault_bytes.extend_from_slice(&header_hmac);
let commit_tag = hmac_sign(&keys.mac_key, &vault_bytes);
vault_bytes.extend_from_slice(&commit_tag);
write_atomic(&config.vault_path, &vault_bytes)?;
Ok(())
}
pub fn open(config: &VaultConfig) -> Result<Self, SecretshError> {
let path = &config.vault_path;
if !path.exists() {
return Err(SecretshError::Vault(VaultError::NotFound {
path: path.to_owned(),
}));
}
check_permissions(path, config.allow_insecure_permissions)?;
let lock_path = lock_path_for(path);
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(O_CLOEXEC)
.open(&lock_path)
.map_err(|e| SecretshError::Io(IoError(e)))?;
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&lock_file);
unsafe { libc::flock(fd, libc::LOCK_SH) };
let mut vault_file = OpenOptions::new()
.read(true)
.custom_flags(O_CLOEXEC)
.open(path)
.map_err(|e| SecretshError::Io(IoError(e)))?;
let mut vault_bytes = Vec::new();
vault_file
.read_to_end(&mut vault_bytes)
.map_err(|e| SecretshError::Io(IoError(e)))?;
drop(vault_file);
unsafe { libc::flock(fd, libc::LOCK_UN) };
drop(lock_file);
let (entries, kdf_params) = parse_and_decrypt(&vault_bytes, &config.master_key_env, path)?;
let mut locked_regions = Vec::with_capacity(entries.len());
for entry in &entries {
let ptr = entry.value.as_ptr();
let len = entry.value.len();
if mlock_region(ptr, len) {
locked_regions.push((ptr as usize, len));
}
}
Ok(Vault {
vault_path: path.to_owned(),
master_key_env: config.master_key_env.clone(),
allow_insecure_permissions: config.allow_insecure_permissions,
kdf_params,
entries,
locked_regions,
})
}
pub fn set(&mut self, key: &str, value: &[u8]) -> Result<(), SecretshError> {
validate_key_name(key)?;
let existing = self.entries.iter().position(|e| e.key == key);
if existing.is_none() && self.entries.len() >= MAX_ENTRIES {
return Err(SecretshError::Vault(VaultError::EntryLimitExceeded {
limit: MAX_ENTRIES,
}));
}
if let Some(idx) = existing {
self.entries[idx].value = Zeroizing::new(value.to_vec());
} else {
self.entries.push(Entry {
key: key.to_owned(),
value: Zeroizing::new(value.to_vec()),
});
}
self.persist()
}
pub fn delete(&mut self, key: &str) -> Result<bool, SecretshError> {
let pos = self.entries.iter().position(|e| e.key == key);
if let Some(idx) = pos {
self.entries.remove(idx);
self.persist()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn list_keys(&self) -> Vec<String> {
self.entries.iter().map(|e| e.key.clone()).collect()
}
pub fn resolve_placeholder(&self, key: &str) -> Option<&[u8]> {
self.entries
.iter()
.find(|e| e.key == key)
.map(|e| e.value.as_slice())
}
pub fn all_secret_values(&self) -> Vec<(&str, &[u8])> {
self.entries
.iter()
.map(|e| (e.key.as_str(), e.value.as_slice()))
.collect()
}
pub fn export(&self, out_path: &Path) -> Result<(), SecretshError> {
let passphrase = read_passphrase(&self.master_key_env)?;
if let Some(parent) = out_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
fs::create_dir_all(parent).map_err(|e| SecretshError::Io(IoError(e)))?;
#[cfg(unix)]
{
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
.map_err(|e| SecretshError::Io(IoError(e)))?;
}
}
}
let rng = SystemRandom::new();
let mut kdf_salt = [0u8; KDF_SALT_LEN];
rng.fill(&mut kdf_salt)
.map_err(|_| SecretshError::Io(IoError(std::io::Error::other("RNG fill failed"))))?;
let keys = derive_keys(&passphrase, &kdf_salt, &self.kdf_params)?;
let entry_count = self.entries.len() as u32;
let header_covered = build_header_covered(&self.kdf_params, &kdf_salt, entry_count);
let header_hmac = hmac_sign(&keys.mac_key, &header_covered);
let entries_bytes = serialize_entries(&self.entries, &keys.enc_key, &rng)?;
let mut vault_bytes = Vec::new();
vault_bytes.extend_from_slice(&header_covered);
vault_bytes.extend_from_slice(&header_hmac);
vault_bytes.extend_from_slice(&entries_bytes);
let commit_tag = hmac_sign(&keys.mac_key, &vault_bytes);
vault_bytes.extend_from_slice(&commit_tag);
write_atomic(out_path, &vault_bytes)?;
Ok(())
}
pub fn import(
&mut self,
import_path: &Path,
import_key_env: Option<&str>,
overwrite: bool,
) -> Result<(usize, usize, usize), SecretshError> {
let import_bytes = {
let mut f = OpenOptions::new()
.read(true)
.custom_flags(O_CLOEXEC)
.open(import_path)
.map_err(|e| SecretshError::Io(IoError(e)))?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)
.map_err(|e| SecretshError::Io(IoError(e)))?;
buf
};
let key_env = import_key_env.unwrap_or(&self.master_key_env);
let (import_entries, _kdf_params) = parse_and_decrypt(&import_bytes, key_env, import_path)?;
let mut added = 0usize;
let mut skipped = 0usize;
let mut replaced = 0usize;
let import_keys: Vec<String> = import_entries.iter().map(|e| e.key.clone()).collect();
for (i, key) in import_keys.iter().enumerate() {
let existing = self.entries.iter().position(|e| e.key == *key);
match existing {
Some(idx) => {
if overwrite {
let old_value = std::mem::replace(
&mut self.entries[idx].value,
Zeroizing::new(import_entries[i].value.as_slice().to_vec()),
);
drop(old_value);
replaced += 1;
} else {
skipped += 1;
}
}
None => {
if self.entries.len() >= MAX_ENTRIES {
return Err(SecretshError::Vault(VaultError::EntryLimitExceeded {
limit: MAX_ENTRIES,
}));
}
let ptr = import_entries[i].value.as_ptr();
let len = import_entries[i].value.len();
if mlock_region(ptr, len) {
self.locked_regions.push((ptr as usize, len));
}
self.entries.push(Entry {
key: key.clone(),
value: Zeroizing::new(import_entries[i].value.as_slice().to_vec()),
});
added += 1;
}
}
}
self.persist()?;
Ok((added, skipped, replaced))
}
pub fn close(&mut self) {
for &(addr, len) in &self.locked_regions {
munlock_region(addr as *const u8, len);
madvise_free(addr as *mut u8, len);
}
self.locked_regions.clear();
for entry in &mut self.entries {
entry.value.zeroize();
}
self.entries.clear();
}
fn persist(&self) -> Result<(), SecretshError> {
let passphrase = read_passphrase(&self.master_key_env)?;
let rng = SystemRandom::new();
let mut kdf_salt = [0u8; KDF_SALT_LEN];
rng.fill(&mut kdf_salt)
.map_err(|_| SecretshError::Io(IoError(std::io::Error::other("RNG fill failed"))))?;
let keys = derive_keys(&passphrase, &kdf_salt, &self.kdf_params)?;
let entry_count = self.entries.len() as u32;
let header_covered = build_header_covered(&self.kdf_params, &kdf_salt, entry_count);
let header_hmac = hmac_sign(&keys.mac_key, &header_covered);
let entries_bytes = serialize_entries(&self.entries, &keys.enc_key, &rng)?;
let mut vault_bytes = Vec::new();
vault_bytes.extend_from_slice(&header_covered);
vault_bytes.extend_from_slice(&header_hmac);
vault_bytes.extend_from_slice(&entries_bytes);
let commit_tag = hmac_sign(&keys.mac_key, &vault_bytes);
vault_bytes.extend_from_slice(&commit_tag);
let _lock = acquire_lock(&self.vault_path)?;
write_atomic(&self.vault_path, &vault_bytes)?;
Ok(())
}
}
fn write_atomic(vault_path: &Path, data: &[u8]) -> Result<(), SecretshError> {
let tmp = tmp_path_for(vault_path);
if tmp.exists() {
fs::remove_file(&tmp).map_err(|e| SecretshError::Io(IoError(e)))?;
}
{
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(O_CLOEXEC)
.open(&tmp)
.map_err(|e| SecretshError::Io(IoError(e)))?;
f.write_all(data)
.map_err(|e| SecretshError::Io(IoError(e)))?;
f.flush().map_err(|e| SecretshError::Io(IoError(e)))?;
f.sync_all().map_err(|e| SecretshError::Io(IoError(e)))?;
}
fs::rename(&tmp, vault_path).map_err(|e| SecretshError::Io(IoError(e)))?;
Ok(())
}
fn parse_and_decrypt(
data: &[u8],
master_key_env: &str,
_vault_path: &Path,
) -> Result<(Vec<Entry>, KdfParams), SecretshError> {
let min_size = HEADER_TOTAL_LEN + HMAC_LEN;
if data.len() < min_size {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: min_size,
found: data.len(),
}));
}
let magic: [u8; 8] = data[0..8].try_into().unwrap();
if &magic != MAGIC {
return Err(SecretshError::Vault(VaultError::BadMagic { found: magic }));
}
let version = data[8];
if version == 0 {
return Err(SecretshError::Vault(VaultError::VersionInvalid {
found: version,
}));
}
if version > VERSION {
return Err(SecretshError::Vault(VaultError::VersionTooNew {
found: version,
supported: VERSION,
}));
}
let m_cost = u32::from_le_bytes(data[10..14].try_into().unwrap());
let t_cost = u32::from_le_bytes(data[14..18].try_into().unwrap());
let p_cost = u32::from_le_bytes(data[18..22].try_into().unwrap());
let kdf_params = KdfParams {
m_cost,
t_cost,
p_cost,
};
let kdf_salt: [u8; KDF_SALT_LEN] = data[22..38].try_into().unwrap();
let entry_count = u32::from_le_bytes(data[38..42].try_into().unwrap()) as usize;
let passphrase = read_passphrase(master_key_env)?;
let keys = derive_keys(&passphrase, &kdf_salt, &kdf_params)?;
let header_hmac_stored: [u8; HMAC_LEN] = data[HEADER_COVERED_LEN..HEADER_TOTAL_LEN]
.try_into()
.unwrap();
if !hmac_verify(
&keys.mac_key,
&data[..HEADER_COVERED_LEN],
&header_hmac_stored,
) {
return Err(SecretshError::Vault(VaultError::HmacMismatch));
}
if data.len() < HMAC_LEN {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: HMAC_LEN,
found: data.len(),
}));
}
let commit_tag_offset = data.len() - HMAC_LEN;
let commit_tag_stored: [u8; HMAC_LEN] = data[commit_tag_offset..].try_into().unwrap();
if !hmac_verify(
&keys.mac_key,
&data[..commit_tag_offset],
&commit_tag_stored,
) {
return Err(SecretshError::Vault(VaultError::CommitTagMismatch));
}
let mut cursor = HEADER_TOTAL_LEN;
let entries_end = commit_tag_offset;
let mut entries = Vec::with_capacity(entry_count);
for idx in 0..entry_count {
if cursor + GCM_NONCE_LEN > entries_end {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: cursor + GCM_NONCE_LEN,
found: data.len(),
}));
}
let nonce: [u8; GCM_NONCE_LEN] = data[cursor..cursor + GCM_NONCE_LEN].try_into().unwrap();
cursor += GCM_NONCE_LEN;
if cursor + 4 > entries_end {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: cursor + 4,
found: data.len(),
}));
}
let ct_len = u32::from_le_bytes(data[cursor..cursor + 4].try_into().unwrap()) as usize;
cursor += 4;
if cursor + ct_len > entries_end {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: cursor + ct_len,
found: data.len(),
}));
}
let ciphertext = &data[cursor..cursor + ct_len];
cursor += ct_len;
let aad = (idx as u32).to_be_bytes();
let plaintext = aes_gcm_open(&keys.enc_key, &nonce, ciphertext, &aad).map_err(|_| {
if idx == 0 {
SecretshError::Vault(VaultError::WrongPassphrase)
} else {
SecretshError::Vault(VaultError::GcmMismatch { index: idx as u32 })
}
})?;
if plaintext.len() < 2 {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: 2,
found: plaintext.len(),
}));
}
let key_name_len = u16::from_le_bytes(plaintext[0..2].try_into().unwrap()) as usize;
if plaintext.len() < 2 + key_name_len {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: 2 + key_name_len,
found: plaintext.len(),
}));
}
let key_name = std::str::from_utf8(&plaintext[2..2 + key_name_len])
.map_err(|_| {
SecretshError::Io(IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"key name is not valid UTF-8",
)))
})?
.to_owned();
let value = Zeroizing::new(plaintext[2 + key_name_len..].to_vec());
entries.push(Entry {
key: key_name,
value,
});
}
if cursor != entries_end {
return Err(SecretshError::Vault(VaultError::Truncated {
expected: entries_end,
found: cursor,
}));
}
Ok((entries, kdf_params))
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn make_config(dir: &TempDir, env_var: &str, passphrase: &str) -> VaultConfig {
std::env::set_var(env_var, passphrase);
VaultConfig {
vault_path: dir.path().join("vault.bin"),
master_key_env: env_var.to_owned(),
allow_insecure_permissions: false,
kdf_memory: Some(8192), }
}
#[test]
fn round_trip_set_and_read() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_RT", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
{
let mut vault = Vault::open(&cfg).expect("open failed");
vault
.set("MY_KEY", b"super-secret-value")
.expect("set failed");
}
{
let vault = Vault::open(&cfg).expect("re-open failed");
let val = vault.resolve_placeholder("MY_KEY").expect("key missing");
assert_eq!(val, b"super-secret-value");
}
}
#[test]
fn round_trip_multiple_keys() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_MULTI", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
{
let mut vault = Vault::open(&cfg).expect("open failed");
vault.set("KEY_A", b"value_a").unwrap();
vault.set("KEY_B", b"value_b").unwrap();
vault.set("KEY_C", b"value_c").unwrap();
}
{
let vault = Vault::open(&cfg).expect("re-open failed");
assert_eq!(vault.resolve_placeholder("KEY_A").unwrap(), b"value_a");
assert_eq!(vault.resolve_placeholder("KEY_B").unwrap(), b"value_b");
assert_eq!(vault.resolve_placeholder("KEY_C").unwrap(), b"value_c");
assert_eq!(vault.list_keys().len(), 3);
}
}
#[test]
fn round_trip_update_existing_key() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_UPDATE", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("K", b"old").unwrap();
}
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("K", b"new").unwrap();
}
{
let vault = Vault::open(&cfg).unwrap();
assert_eq!(vault.resolve_placeholder("K").unwrap(), b"new");
assert_eq!(vault.list_keys().len(), 1);
}
}
#[test]
fn round_trip_delete() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_DEL", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("TO_DELETE", b"gone").unwrap();
vault.set("KEEP", b"here").unwrap();
}
{
let mut vault = Vault::open(&cfg).unwrap();
let removed = vault.delete("TO_DELETE").unwrap();
assert!(removed);
let not_found = vault.delete("NONEXISTENT").unwrap();
assert!(!not_found);
}
{
let vault = Vault::open(&cfg).unwrap();
assert!(vault.resolve_placeholder("TO_DELETE").is_none());
assert_eq!(vault.resolve_placeholder("KEEP").unwrap(), b"here");
}
}
#[test]
fn header_hmac_mismatch_detected() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_HMAC", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
let mut raw = fs::read(&cfg.vault_path).unwrap();
raw[42] ^= 0xFF;
fs::write(&cfg.vault_path, &raw).unwrap();
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(err, SecretshError::Vault(VaultError::HmacMismatch)),
"expected HmacMismatch, got: {err:?}"
);
}
#[test]
fn commit_tag_mismatch_detected() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_COMMIT", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
let mut raw = fs::read(&cfg.vault_path).unwrap();
let last = raw.len() - 1;
raw[last] ^= 0xFF;
fs::write(&cfg.vault_path, &raw).unwrap();
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(
err,
SecretshError::Vault(VaultError::CommitTagMismatch)
| SecretshError::Vault(VaultError::HmacMismatch)
),
"expected CommitTagMismatch or HmacMismatch, got: {err:?}"
);
}
#[test]
fn wrong_passphrase_returns_error() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_PASS", "correct-horse-battery-staple");
Vault::init(&cfg).expect("init failed");
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("SECRET", b"value").unwrap();
}
std::env::set_var("VAULT_TEST_PASS", "wrong-passphrase-here");
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(
err,
SecretshError::Vault(VaultError::WrongPassphrase)
| SecretshError::Vault(VaultError::HmacMismatch)
),
"expected WrongPassphrase or HmacMismatch, got: {err:?}"
);
}
#[test]
fn valid_key_names_accepted() {
for name in &["A", "_", "ABC", "a_b_c", "KEY123", "_PRIVATE"] {
validate_key_name(name).unwrap_or_else(|e| panic!("rejected valid name {name:?}: {e}"));
}
}
#[test]
fn invalid_key_names_rejected() {
for name in &["", "1START", "has-hyphen", "has space", "has.dot"] {
assert!(
validate_key_name(name).is_err(),
"should have rejected {name:?}"
);
}
}
#[test]
fn set_with_invalid_key_name_returns_error() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_KEYVAL", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
let mut vault = Vault::open(&cfg).unwrap();
let err = vault.set("invalid-key!", b"value").unwrap_err();
assert!(
matches!(err, SecretshError::Tokenization(_)),
"expected Tokenization error, got: {err:?}"
);
}
#[test]
fn entry_limit_enforced() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_LIMIT", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
let mut vault = Vault::open(&cfg).unwrap();
for i in 0..MAX_ENTRIES {
vault.entries.push(Entry {
key: format!("KEY_{i:05}"),
value: Zeroizing::new(b"v".to_vec()),
});
}
let err = vault.set("NEW_KEY", b"value").unwrap_err();
assert!(
matches!(
err,
SecretshError::Vault(VaultError::EntryLimitExceeded { .. })
),
"expected EntryLimitExceeded, got: {err:?}"
);
}
#[test]
fn insecure_permissions_rejected() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_PERM", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
fs::set_permissions(&cfg.vault_path, fs::Permissions::from_mode(0o644)).unwrap();
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(
err,
SecretshError::Vault(VaultError::InsecurePermissions { .. })
),
"expected InsecurePermissions, got: {err:?}"
);
}
#[test]
fn insecure_permissions_allowed_with_flag() {
let dir = TempDir::new().unwrap();
let mut cfg = make_config(
&dir,
"VAULT_TEST_PERM_ALLOW",
"correct-horse-battery-staple",
);
Vault::init(&cfg).unwrap();
fs::set_permissions(&cfg.vault_path, fs::Permissions::from_mode(0o644)).unwrap();
cfg.allow_insecure_permissions = true;
Vault::open(&cfg).expect("should open with allow_insecure_permissions=true");
}
#[test]
fn short_passphrase_rejected_on_init() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_SHORT_PASS", "short");
let err = Vault::init(&cfg).unwrap_err();
assert!(
matches!(
err,
SecretshError::MasterKey(MasterKeyError::PassphraseTooShort { .. })
),
"expected PassphraseTooShort, got: {err:?}"
);
}
#[test]
fn short_passphrase_allowed_with_no_check() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_SHORT_PASS_OK", "short");
Vault::init_no_passphrase_check(&cfg)
.expect("should succeed with no_passphrase_check=true");
}
#[test]
fn open_nonexistent_vault_returns_not_found() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_NF", "correct-horse-battery-staple");
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(err, SecretshError::Vault(VaultError::NotFound { .. })),
"expected NotFound, got: {err:?}"
);
}
#[test]
fn binary_value_round_trip() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_BIN", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
let binary_val: Vec<u8> = (0u8..=255).collect();
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("BIN_KEY", &binary_val).unwrap();
}
{
let vault = Vault::open(&cfg).unwrap();
assert_eq!(
vault.resolve_placeholder("BIN_KEY").unwrap(),
&binary_val[..]
);
}
}
#[test]
fn all_secret_values_returns_all_entries() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_ALL", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("A", b"1").unwrap();
vault.set("B", b"2").unwrap();
}
let vault = Vault::open(&cfg).unwrap();
let all = vault.all_secret_values();
assert_eq!(all.len(), 2);
let keys: Vec<&str> = all.iter().map(|(k, _)| *k).collect();
assert!(keys.contains(&"A"));
assert!(keys.contains(&"B"));
}
#[test]
fn close_clears_entries() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_CLOSE", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
let mut vault = Vault::open(&cfg).unwrap();
vault.set("K", b"secret").unwrap();
let mut vault2 = Vault::open(&cfg).unwrap();
assert!(vault2.resolve_placeholder("K").is_some());
vault2.close();
assert!(vault2.resolve_placeholder("K").is_none());
assert!(vault2.list_keys().is_empty());
}
#[test]
fn bad_magic_detected() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_MAGIC", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
let mut raw = fs::read(&cfg.vault_path).unwrap();
raw[0] = b'X'; fs::write(&cfg.vault_path, &raw).unwrap();
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(err, SecretshError::Vault(VaultError::BadMagic { .. })),
"expected BadMagic, got: {err:?}"
);
}
#[test]
fn truncated_file_detected() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_TRUNC", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
let raw = fs::read(&cfg.vault_path).unwrap();
fs::write(&cfg.vault_path, &raw[..10]).unwrap();
let err = Vault::open(&cfg).unwrap_err();
assert!(
matches!(err, SecretshError::Vault(VaultError::Truncated { .. })),
"expected Truncated, got: {err:?}"
);
}
#[test]
fn export_import_round_trip() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_EXPORT", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("KEY_A", b"value_a").unwrap();
vault.set("KEY_B", b"value_b").unwrap();
}
let export_path = dir.path().join("export.vault.bin");
{
let vault = Vault::open(&cfg).unwrap();
vault.export(&export_path).unwrap();
}
assert!(export_path.exists(), "export file should exist");
{
let export_cfg = VaultConfig {
vault_path: export_path.clone(),
master_key_env: "VAULT_TEST_EXPORT".to_owned(),
allow_insecure_permissions: false,
kdf_memory: None,
};
let vault = Vault::open(&export_cfg).unwrap();
assert_eq!(vault.list_keys().len(), 2);
assert_eq!(vault.resolve_placeholder("KEY_A").unwrap(), b"value_a");
assert_eq!(vault.resolve_placeholder("KEY_B").unwrap(), b"value_b");
}
}
#[test]
fn import_merges_new_entries() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_IMPORT", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("EXISTING_KEY", b"existing_value").unwrap();
}
let import_vault_path = dir.path().join("import_vault.bin");
let import_cfg = VaultConfig {
vault_path: import_vault_path.clone(),
master_key_env: "VAULT_TEST_IMPORT".to_owned(),
allow_insecure_permissions: false,
kdf_memory: Some(8192),
};
Vault::init(&import_cfg).unwrap();
{
let mut vault = Vault::open(&import_cfg).unwrap();
vault.set("NEW_KEY", b"new_value").unwrap();
}
{
let mut vault = Vault::open(&cfg).unwrap();
assert_eq!(vault.list_keys(), vec!["EXISTING_KEY"]);
let (added, skipped, replaced) = vault.import(&import_vault_path, None, false).unwrap();
assert_eq!(added, 1);
assert_eq!(skipped, 0);
assert_eq!(replaced, 0);
}
{
let vault = Vault::open(&cfg).unwrap();
assert_eq!(vault.list_keys().len(), 2);
assert_eq!(
vault.resolve_placeholder("EXISTING_KEY").unwrap(),
b"existing_value"
);
assert_eq!(vault.resolve_placeholder("NEW_KEY").unwrap(), b"new_value");
}
}
#[test]
fn import_skips_existing_without_overwrite() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_IMP_SKIP", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("SHARED_KEY", b"original_value").unwrap();
}
let import_vault_path = dir.path().join("import_vault.bin");
let import_cfg = VaultConfig {
vault_path: import_vault_path.clone(),
master_key_env: "VAULT_TEST_IMP_SKIP".to_owned(),
allow_insecure_permissions: false,
kdf_memory: Some(8192),
};
Vault::init(&import_cfg).unwrap();
{
let mut vault = Vault::open(&import_cfg).unwrap();
vault.set("SHARED_KEY", b"new_value").unwrap();
}
{
let mut vault = Vault::open(&cfg).unwrap();
let (added, skipped, replaced) = vault.import(&import_vault_path, None, false).unwrap();
assert_eq!(added, 0);
assert_eq!(skipped, 1);
assert_eq!(replaced, 0);
assert_eq!(
vault.resolve_placeholder("SHARED_KEY").unwrap(),
b"original_value"
);
}
}
#[test]
fn import_overwrites_with_flag() {
let dir = TempDir::new().unwrap();
let cfg = make_config(&dir, "VAULT_TEST_IMP_OVER", "correct-horse-battery-staple");
Vault::init(&cfg).unwrap();
{
let mut vault = Vault::open(&cfg).unwrap();
vault.set("SHARED_KEY", b"original_value").unwrap();
}
let import_vault_path = dir.path().join("import_vault.bin");
let import_cfg = VaultConfig {
vault_path: import_vault_path.clone(),
master_key_env: "VAULT_TEST_IMP_OVER".to_owned(),
allow_insecure_permissions: false,
kdf_memory: Some(8192),
};
Vault::init(&import_cfg).unwrap();
{
let mut vault = Vault::open(&import_cfg).unwrap();
vault.set("SHARED_KEY", b"new_value").unwrap();
}
{
let mut vault = Vault::open(&cfg).unwrap();
let (added, skipped, replaced) = vault.import(&import_vault_path, None, true).unwrap();
assert_eq!(added, 0);
assert_eq!(skipped, 0);
assert_eq!(replaced, 1);
assert_eq!(
vault.resolve_placeholder("SHARED_KEY").unwrap(),
b"new_value"
);
}
}
}