use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, KeyInit};
use zeroize::Zeroizing;
use crate::error::Error;
use crate::keychain::MasterKey;
const NONCE_SIZE: usize = 12;
pub const MAX_SECRET_SIZE_BYTES: usize = 64 * 1024;
#[derive(Debug)]
pub struct Vault {
root: PathBuf,
master_key: MasterKey,
}
impl Vault {
pub fn validate_root(root: &Path) -> Result<(), Error> {
reject_unsafe_vault_root(root)
}
pub fn open_default() -> Result<Self, Error> {
let root = default_vault_root()?;
Self::open(&root)
}
pub fn open_default_with_passphrase(
passphrase: &zeroize::Zeroizing<String>,
) -> Result<Self, Error> {
let root = default_vault_root()?;
Self::open_with_passphrase(&root, passphrase)
}
pub fn open_with_passphrase(
root: &Path,
passphrase: &zeroize::Zeroizing<String>,
) -> Result<Self, Error> {
Self::open_inner(root, Some(passphrase))
}
pub fn open(root: &Path) -> Result<Self, Error> {
Self::open_inner(root, None)
}
fn open_inner(
root: &Path,
passphrase: Option<&zeroize::Zeroizing<String>>,
) -> Result<Self, Error> {
reject_unsafe_vault_root(root)?;
crate::guard::check_self_preload()?;
crate::guard::harden_process();
crate::guard::verify_not_symlink(root)?;
let vault_dir = root.join("vault");
crate::guard::verify_not_symlink(&vault_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
if !vault_dir.exists() {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&vault_dir)
.map_err(Error::StorageIo)?;
}
}
#[cfg(not(unix))]
{
fs::create_dir_all(&vault_dir).map_err(Error::StorageIo)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
.map_err(Error::StorageIo)?;
fs::set_permissions(&vault_dir, fs::Permissions::from_mode(0o700))
.map_err(Error::StorageIo)?;
};
let mk_path = root.join("master.key");
crate::guard::verify_not_symlink(&mk_path)?;
crate::guard::verify_not_symlink(&root.join("policy.toml"))?;
crate::guard::verify_not_symlink(&root.join("security.toml"))?;
let master_key = match passphrase {
Some(p) => crate::keychain::open_master_key_with_passphrase(root, p)?,
None => crate::keychain::open_master_key(root)?,
};
let sec_config = crate::security_config::load_config(root, master_key.as_bytes())?;
if sec_config.totp_required {
if passphrase.is_some() {
return Err(Error::CryptoFailure(
"this vault has TOTP enabled — use the `envseal` CLI \
to unlock; in-window TOTP entry is planned for v0.3"
.to_string(),
));
}
verify_totp_factor(root, &sec_config, master_key.as_bytes())?;
}
Ok(Self {
root: root.to_path_buf(),
master_key,
})
}
#[cfg(any(test, feature = "test-backdoors"))]
#[doc(hidden)]
pub fn open_with_key(root: &Path, master_key: MasterKey) -> Result<Self, Error> {
reject_unsafe_vault_root(root)?;
crate::guard::check_self_preload()?;
crate::guard::harden_process();
crate::guard::verify_not_symlink(root)?;
let vault_dir = root.join("vault");
crate::guard::verify_not_symlink(&vault_dir)?;
fs::create_dir_all(&vault_dir)?;
let mk_path = root.join("master.key");
crate::guard::verify_not_symlink(&mk_path)?;
crate::guard::verify_not_symlink(&root.join("policy.toml"))?;
crate::guard::verify_not_symlink(&root.join("security.toml"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(root, fs::Permissions::from_mode(0o700));
let _ = fs::set_permissions(&vault_dir, fs::Permissions::from_mode(0o700));
}
Ok(Self {
root: root.to_path_buf(),
master_key,
})
}
pub fn store(&self, name: &str, value: &[u8], force: bool) -> Result<(), Error> {
if value.len() > MAX_SECRET_SIZE_BYTES {
return Err(Error::CryptoFailure(format!(
"secret exceeds max size: {} bytes > {} bytes",
value.len(),
MAX_SECRET_SIZE_BYTES
)));
}
validate_name(name)?;
let path = self.secret_path(name);
crate::guard::verify_not_symlink(&path)?;
let cipher = Aes256Gcm::new(self.master_key.as_aes_key());
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(
&nonce,
aes_gcm::aead::Payload {
msg: value,
aad: name.as_bytes(),
},
)
.map_err(|e| Error::CryptoFailure(format!("encryption failed: {e}")))?;
let mut sealed = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
sealed.extend_from_slice(&nonce);
sealed.extend_from_slice(&ciphertext);
if force {
use std::sync::atomic::{AtomicU64, Ordering};
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let thread_id = format!("{:?}", std::thread::current().id());
let tmp_name = format!(
".{}.seal.tmp.{}.{}.{counter}.{thread_id}",
name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
);
let tmp_path = self.root.join("vault").join(tmp_name);
let mut file = open_secret_for_create(&tmp_path, name)?;
file.write_all(&sealed).map_err(Error::StorageIo)?;
file.sync_all().map_err(Error::StorageIo)?;
fs::rename(&tmp_path, &path)?;
} else {
let mut file = open_secret_for_create(&path, name)?;
file.write_all(&sealed).map_err(Error::StorageIo)?;
file.sync_all().map_err(Error::StorageIo)?;
}
Ok(())
}
pub fn decrypt(&self, name: &str) -> Result<Zeroizing<Vec<u8>>, Error> {
if name.eq_ignore_ascii_case("ctf-flag") {
crate::audit::log_required_at(
self.root(),
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification:
"critical [ctf.flag.decrypt_attempt] refused vault.decrypt(\"ctf-flag\")"
.to_string(),
},
)
.map_err(|e| Error::AuditLogFailed(e.to_string()))?;
return Err(Error::CryptoFailure(
"ctf-flag is non-decryptable by design. The CTF flag can only \
be checked via `envseal ctf verify <flag>`, which compares \
SHA-256 hashes — never the plaintext. Tear down the challenge \
with `envseal ctf reset` if you need to clear it."
.to_string(),
));
}
let path = self.secret_path(name);
let mut file = std::fs::OpenOptions::new();
file.read(true);
#[cfg(unix)]
{
file.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
let handle = file.open(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::SecretNotFound(name.to_string())
} else {
Error::StorageIo(e)
}
})?;
let mut sealed = Vec::new();
handle
.take(MAX_SECRET_SIZE_BYTES as u64 + 100)
.read_to_end(&mut sealed)
.map_err(Error::StorageIo)?;
if sealed.len() < NONCE_SIZE {
return Err(Error::CryptoFailure(
"sealed file too short: possible corruption".to_string(),
));
}
let (nonce_bytes, ciphertext) = sealed.split_at(NONCE_SIZE);
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
let cipher = Aes256Gcm::new(self.master_key.as_aes_key());
let plaintext = cipher
.decrypt(
nonce,
aes_gcm::aead::Payload {
msg: ciphertext,
aad: name.as_bytes(),
},
)
.map_err(|e| Error::CryptoFailure(format!("decryption failed: {e}")))?;
Ok(Zeroizing::new(plaintext))
}
pub fn list(&self) -> Result<Vec<String>, Error> {
let vault_dir = self.root.join("vault");
let mut names = Vec::new();
if vault_dir.exists() {
for entry in fs::read_dir(&vault_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("seal") {
crate::guard::verify_not_symlink(&path)?;
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
names.push(stem.to_string());
}
}
}
}
names.sort();
Ok(names)
}
pub fn revoke(&self, name: &str) -> Result<(), Error> {
let path = self.secret_path(name);
crate::guard::verify_not_symlink(&path)?;
let mut file = open_secret_for_revoke(&path).map_err(|e| match e {
Error::StorageIo(ref io) if io.kind() == std::io::ErrorKind::NotFound => {
Error::SecretNotFound(name.to_string())
}
other => other,
})?;
let len = usize::try_from(file.metadata().map_err(Error::StorageIo)?.len()).unwrap_or(0);
if len > 0 {
file.seek(SeekFrom::Start(0)).map_err(Error::StorageIo)?;
let buf = [0u8; 8192];
let mut remaining = len;
while remaining > 0 {
let n = remaining.min(buf.len());
file.write_all(&buf[..n]).map_err(Error::StorageIo)?;
remaining -= n;
}
file.sync_all().map_err(Error::StorageIo)?;
}
drop(file);
fs::remove_file(&path)?;
Ok(())
}
pub fn policy_path(&self) -> PathBuf {
self.root.join("policy.toml")
}
pub fn policy_sig_path(&self) -> PathBuf {
self.root.join("policy.sig")
}
pub fn master_key(&self) -> &MasterKey {
&self.master_key
}
pub fn secret_path(&self, name: &str) -> PathBuf {
self.root.join("vault").join(format!("{name}.seal"))
}
#[must_use]
pub fn has_secret(&self, name: &str) -> bool {
self.secret_path(name).exists()
}
pub fn master_key_bytes(&self) -> &[u8; 32] {
self.master_key.as_bytes()
}
pub fn root(&self) -> &std::path::Path {
&self.root
}
}
fn open_secret_for_revoke(path: &Path) -> Result<std::fs::File, Error> {
#[cfg(unix)]
{
std::fs::OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)
.map_err(Error::StorageIo)
}
#[cfg(not(unix))]
{
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(Error::StorageIo)
}
}
fn open_secret_for_create(path: &Path, secret_name: &str) -> Result<std::fs::File, Error> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).truncate(true);
opts.create_new(true);
#[cfg(unix)]
{
opts.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
opts.open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
Error::SecretAlreadyExists(secret_name.to_string())
} else {
Error::StorageIo(e)
}
})
}
fn validate_name(name: &str) -> Result<(), Error> {
const BLOCKED_CHARS: &[char] = &[
'$', '`', '!', '&', '|', ';', '(', ')', '{', '}', '<', '>', '"', '\'', '\n', '\r', '\t',
'*', '?', '[', ']', '~', '#',
];
if name.is_empty() {
return Err(Error::CryptoFailure(
"secret name must not be empty".to_string(),
));
}
if name.len() > 200 {
return Err(Error::CryptoFailure(format!(
"secret name too long ({} chars, max 200)",
name.len()
)));
}
if name.contains('/') || name.contains('\\') || name.contains('\0') || name.starts_with('.') {
return Err(Error::CryptoFailure(format!(
"invalid secret name '{name}': must not contain path separators or start with '.'"
)));
}
if name.contains("..") {
return Err(Error::CryptoFailure(format!(
"invalid secret name '{name}': path traversal detected"
)));
}
for ch in BLOCKED_CHARS {
if name.contains(*ch) {
return Err(Error::CryptoFailure(format!(
"invalid secret name '{name}': contains forbidden character '{ch}'"
)));
}
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.@+=,".contains(c))
{
return Err(Error::CryptoFailure(format!(
"invalid secret name '{name}': only ASCII alphanumeric, hyphens, underscores, \
dots, @, +, =, and commas are allowed"
)));
}
Ok(())
}
fn reject_unsafe_vault_root(root: &Path) -> Result<(), Error> {
const BLOCKED_PREFIXES: &[&str] = &[
"/tmp",
"/var/tmp",
"/dev/shm",
"/dev/mqueue",
"/run/user", ];
let root_str = root.to_string_lossy();
for prefix in BLOCKED_PREFIXES {
if root_str.starts_with(prefix) {
return Err(Error::EnvironmentCompromised(format!(
"vault root '{}' is in a world-writable directory ({prefix}). \
this is a hard block — envseal will never store secrets here. \
use the default location (~/.config/envseal) instead.",
root.display()
)));
}
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if root.exists() {
if let Ok(meta) = fs::metadata(root) {
let mode = meta.mode();
if mode & 0o002 != 0 {
return Err(Error::EnvironmentCompromised(format!(
"vault root '{}' is world-writable (mode={mode:04o}). \
this is a hard block — secrets must not be stored in \
world-writable directories.",
root.display()
)));
}
if meta.uid() != unsafe { libc::geteuid() } {
return Err(Error::EnvironmentCompromised(format!(
"vault root '{}' is not owned by the current user (uid={}). \
this is a hard block.",
root.display(),
meta.uid()
)));
}
}
}
}
Ok(())
}
pub(crate) fn default_vault_root() -> Result<PathBuf, Error> {
let config_dir = dirs_path()?;
Ok(config_dir.join("envseal"))
}
fn dirs_path() -> Result<PathBuf, Error> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let xdg_path = PathBuf::from(&xdg);
let xdg_str = xdg_path.to_string_lossy();
if xdg_str.starts_with("/tmp")
|| xdg_str.starts_with("/var/tmp")
|| xdg_str.starts_with("/dev/shm")
{
let _ = crate::guard::emit_signal_inline(
crate::guard::Signal::new(
crate::guard::SignalId::new("disk.xdg_config.untrusted_prefix"),
crate::guard::Category::DiskPermissions,
crate::guard::Severity::Warn,
"XDG_CONFIG_HOME points to a world-writable location",
format!(
"XDG_CONFIG_HOME='{xdg}' resolves under /tmp, /var/tmp, or /dev/shm — \
falling back to $HOME/.config"
),
"unset XDG_CONFIG_HOME or point it at a user-owned directory",
),
&crate::security_config::load_system_defaults(),
);
} else {
#[cfg(unix)]
{
if xdg_path.exists() {
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = std::fs::metadata(&xdg_path) {
if meta.uid() != unsafe { libc::geteuid() } {
return Err(Error::StorageIo(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("XDG_CONFIG_HOME ({xdg}) is not owned by the current user"),
)));
}
let mode = meta.mode();
if mode & 0o002 != 0 {
let _ = crate::guard::emit_signal_inline(
crate::guard::Signal::new(
crate::guard::SignalId::new("disk.xdg_config.world_writable"),
crate::guard::Category::DiskPermissions,
crate::guard::Severity::Warn,
"XDG_CONFIG_HOME is world-writable",
format!(
"XDG_CONFIG_HOME='{xdg}' has mode {mode:04o} — \
falling back to $HOME/.config"
),
"chmod o-w on the directory or unset XDG_CONFIG_HOME",
),
&crate::security_config::load_system_defaults(),
);
} else {
return Ok(xdg_path);
}
}
} else {
return Ok(xdg_path);
}
}
#[cfg(not(unix))]
{
return Ok(xdg_path);
}
}
}
let home = std::env::var("HOME").map_err(|_| {
Error::StorageIo(std::io::Error::new(
std::io::ErrorKind::NotFound,
"HOME environment variable not set",
))
})?;
Ok(PathBuf::from(home).join(".config"))
}
fn verify_totp_factor(
root: &Path,
config: &crate::security_config::SecurityConfig,
master_key: &[u8; 32],
) -> Result<(), Error> {
let encrypted_secret = config.totp_secret_encrypted.as_deref().ok_or_else(|| {
Error::CryptoFailure(
"TOTP is required but no secret is configured. \
Run `envseal security totp-setup` first."
.to_string(),
)
})?;
let secret_b32 = crate::totp::decrypt_secret(encrypted_secret, master_key)?;
for attempt in 1..=3 {
let user_code = crate::gui::request_totp_code(attempt)?;
match crate::totp::verify_code(&secret_b32, &user_code) {
Ok(true) => {
crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::ChallengeAttempted { passed: true },
)?;
return Ok(());
}
Ok(false) => {
crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::ChallengeAttempted { passed: false },
)?;
if attempt < 3 {
eprintln!("envseal: invalid TOTP code (attempt {attempt}/3)");
}
}
Err(e) => return Err(e),
}
}
Err(Error::CryptoFailure(
"TOTP verification failed after 3 attempts".to_string(),
))
}