use serde::Deserialize;
use std::path::{Path, PathBuf};
use super::tiers::{SecurityConfig, SecurityTier};
use crate::error::Error;
use crate::hex;
use crate::vault::sealed_blob;
const SECURITY_CONFIG_DOMAIN: &[u8] = b"security_config.v1";
#[must_use]
pub fn security_config_path(root: &Path) -> PathBuf {
root.join("security.toml")
}
#[must_use]
pub fn security_config_sealed_path(root: &Path) -> PathBuf {
root.join("security.sealed")
}
#[must_use]
pub fn security_config_tripwire_path(root: &Path) -> PathBuf {
root.join("security.tripwire")
}
fn compute_security_tripwire(
master_key: &[u8; 32],
sealed_bytes: &[u8],
) -> Result<[u8; 32], Error> {
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let hk = Hkdf::<Sha256>::new(Some(b"envseal-security-tripwire-v1"), master_key);
let mut hmac_key = zeroize::Zeroizing::new([0u8; 32]);
hk.expand(b"envseal-security-tripwire", hmac_key.as_mut())
.map_err(|_| Error::CryptoFailure("HKDF expand for tripwire failed".to_string()))?;
let mut mac = HmacSha256::new_from_slice(hmac_key.as_ref())
.map_err(|e| Error::CryptoFailure(format!("HMAC init for tripwire failed: {e}")))?;
mac.update(sealed_bytes);
let tag = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&tag);
Ok(out)
}
#[must_use]
pub fn load_system_defaults() -> SecurityConfig {
let mut config = SecurityConfig::default();
let _ = apply_system_overrides(&mut config, None);
config
}
pub fn load_config(root: &Path, master_key: &[u8; 32]) -> Result<SecurityConfig, Error> {
const MAX_SEALED_CONFIG_BYTES: u64 = 10 * 1024 * 1024;
let lock_path = root.join("security.lock");
crate::guard::verify_not_symlink(&lock_path)?;
let _lock = advisory_lock::acquire(&lock_path, false)?;
let mut config = load_system_defaults();
let tripwire_path = security_config_tripwire_path(root);
let sealed_path = security_config_sealed_path(root);
crate::guard::verify_not_symlink(&tripwire_path)?;
let tripwire_present = tripwire_path.exists();
let sealed_present = sealed_path.exists();
if tripwire_present && !sealed_present {
let _ = crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification: "critical [security.tripwire.deleted_sealed] \
security.tripwire present but security.sealed missing — \
deletion attack against the security config detected"
.to_string(),
},
);
return Err(Error::PolicyTampered(format!(
"security.tripwire is present at {} but security.sealed is missing. \
Someone deleted the security config; envseal refuses to fall back \
to defaults (which would silently disable TOTP / relay / lockdown \
tier). Restore security.sealed from backup, or run \
`envseal security reset` if you intentionally want to start over.",
tripwire_path.display()
)));
}
if sealed_path.exists() {
crate::guard::verify_not_symlink(&sealed_path)?;
let meta = std::fs::metadata(&sealed_path)?;
if meta.len() > MAX_SEALED_CONFIG_BYTES {
return Err(Error::PolicyTampered(format!(
"security.sealed at {} is {} bytes (limit {MAX_SEALED_CONFIG_BYTES}); \
refusing to read — a real config is on the order of 1 KiB.",
sealed_path.display(),
meta.len(),
)));
}
let blob = std::fs::read(&sealed_path)?;
if tripwire_present {
let expected = compute_security_tripwire(master_key, &blob)?;
let on_disk = std::fs::read(&tripwire_path)?;
if on_disk.len() != expected.len()
|| !crate::guard::constant_time_eq(&on_disk, &expected)
{
let _ = crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification: "critical [security.tripwire.mismatch] \
tripwire HMAC does not match on-disk security.sealed \
— replay or partial-write tamper detected"
.to_string(),
},
);
return Err(Error::PolicyTampered(
"security.tripwire HMAC does not match security.sealed; \
a stale sealed file was substituted (replay attack) or a \
write was interrupted. Restore from backup or run \
`envseal security reset`."
.to_string(),
));
}
}
let plaintext = sealed_blob::unseal(&blob, master_key, SECURITY_CONFIG_DOMAIN)?;
let body = std::str::from_utf8(&plaintext)
.map_err(|e| Error::PolicyTampered(format!("security.sealed not utf-8: {e}")))?;
config = toml::from_str(body)
.map_err(|e| Error::PolicyTampered(format!("security.sealed parse failed: {e}")))?;
} else {
let path = security_config_path(root);
crate::guard::verify_not_symlink(&path)?;
if path.exists() {
use std::io::Read;
let f = std::fs::File::open(&path)?;
let mut content = String::new();
f.take(1024 * 1024).read_to_string(&mut content)?;
let (body, expected_hmac) = split_signed_content(&content)?;
let computed = compute_hmac(master_key, body.as_bytes())?;
if !crate::guard::constant_time_eq(computed.as_bytes(), expected_hmac.as_bytes()) {
return Err(Error::PolicyTampered(
"security.toml HMAC mismatch (tamper detected)".to_string(),
));
}
config = toml::from_str(body)
.map_err(|e| Error::PolicyTampered(format!("security.toml parse failed: {e}")))?;
}
}
if config.tier < SecurityTier::Standard {
config.tier = SecurityTier::Standard;
}
apply_system_overrides(&mut config, Some(master_key))?;
Ok(config)
}
pub fn save_config(
root: &Path,
config: &SecurityConfig,
master_key: &[u8; 32],
) -> Result<(), Error> {
let lock_path = root.join("security.lock");
crate::guard::verify_not_symlink(&lock_path)?;
let _lock = advisory_lock::acquire(&lock_path, true)?;
let body = toml::to_string_pretty(config)
.map_err(|e| Error::CryptoFailure(format!("failed to serialize security config: {e}")))?;
let blob = sealed_blob::seal(body.as_bytes(), master_key, SECURITY_CONFIG_DOMAIN)?;
let sealed_path = security_config_sealed_path(root);
let rnd = rand::random::<u64>();
let tmp_path = sealed_path.with_extension(format!("sealed.{rnd:016x}.tmp"));
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&tmp_path)
.map_err(Error::StorageIo)?;
f.write_all(&blob).map_err(Error::StorageIo)?;
f.sync_all().map_err(Error::StorageIo)?;
}
#[cfg(windows)]
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)
.map_err(Error::StorageIo)?;
f.write_all(&blob).map_err(Error::StorageIo)?;
f.sync_all().map_err(Error::StorageIo)?;
crate::policy::windows_acl::set_owner_only_dacl(&tmp_path)?;
}
#[cfg(not(any(unix, windows)))]
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp_path)
.map_err(Error::StorageIo)?;
f.write_all(&blob).map_err(Error::StorageIo)?;
f.sync_all().map_err(Error::StorageIo)?;
}
std::fs::rename(&tmp_path, &sealed_path)?;
let tripwire_path = security_config_tripwire_path(root);
let tripwire_tmp = tripwire_path.with_extension(format!("tripwire.{rnd:016x}.tmp"));
let tripwire = compute_security_tripwire(master_key, &blob)?;
{
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tripwire_tmp).map_err(Error::StorageIo)?;
f.write_all(&tripwire).map_err(Error::StorageIo)?;
f.sync_all().map_err(Error::StorageIo)?;
}
#[cfg(windows)]
{
crate::policy::windows_acl::set_owner_only_dacl(&tripwire_tmp)?;
}
if let Err(e) = std::fs::rename(&tripwire_tmp, &tripwire_path) {
let _ = std::fs::remove_file(&tripwire_tmp);
return Err(Error::StorageIo(e));
}
let legacy_path = security_config_path(root);
if legacy_path.exists() {
let _ = std::fs::remove_file(&legacy_path);
}
Ok(())
}
#[derive(Deserialize)]
struct SysOverride {
force_lockdown: Option<bool>,
custom_ui_cmd: Option<String>,
}
fn apply_system_overrides(
config: &mut SecurityConfig,
master_key: Option<&[u8; 32]>,
) -> Result<(), Error> {
use std::io::Read;
let sys_path = Path::new("/etc/envseal/system.toml");
if !sys_path.exists() {
return Ok(());
}
if crate::guard::verify_not_symlink(sys_path).is_err() {
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let Ok(meta) = std::fs::metadata(sys_path) else {
return Ok(());
};
if meta.uid() != 0 {
return Ok(());
}
if meta.mode() & 0o022 != 0 {
return Ok(());
}
}
let Ok(content) = (|| -> std::io::Result<String> {
let f = std::fs::File::open(sys_path)?;
let mut s = String::new();
f.take(1024 * 1024).read_to_string(&mut s)?;
Ok(s)
})() else {
return Ok(());
};
let sig_path = Path::new("/etc/envseal/system.toml.sig");
if sig_path.exists() {
if let Some(key) = master_key {
let sig = (|| -> std::io::Result<String> {
let f = std::fs::File::open(sig_path)?;
let mut s = String::new();
f.take(64 * 1024).read_to_string(&mut s)?;
Ok(s)
})()
.map_err(Error::StorageIo)?;
let sig = sig.trim();
let computed = compute_hmac(key, content.as_bytes())?;
if !crate::guard::constant_time_eq(computed.as_bytes(), sig.as_bytes()) {
return Err(Error::PolicyTampered(
"system.toml HMAC mismatch (tamper detected)".to_string(),
));
}
} else {
return Err(Error::PolicyTampered(
"system.toml.sig exists but no master key available for verification".to_string(),
));
}
} else {
eprintln!(
"envseal: warning: /etc/envseal/system.toml has no .sig file; \
integrity verification is recommended"
);
}
let Ok(sys_overrides) = toml::from_str::<SysOverride>(&content) else {
return Ok(());
};
if sys_overrides.force_lockdown == Some(true) {
config.apply_preset(SecurityTier::Lockdown);
}
if sys_overrides.custom_ui_cmd.is_some() {
config.custom_ui_cmd = sys_overrides.custom_ui_cmd;
}
Ok(())
}
fn compute_hmac(key: &[u8; 32], data: &[u8]) -> Result<String, Error> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let hmac_key = crate::keychain::derive_hmac_key(key)?;
let mut mac = HmacSha256::new_from_slice(hmac_key.as_ref())
.map_err(|e| Error::CryptoFailure(format!("HMAC init failed (unexpected): {e}")))?;
mac.update(data);
let result = mac.finalize();
Ok(hex::encode(result.into_bytes()))
}
fn split_signed_content(content: &str) -> Result<(&str, &str), Error> {
if let Some(pos) = content.rfind("\n# hmac = \"") {
let body = &content[..pos];
let hmac_line = &content[pos..];
if let Some(start) = hmac_line.find('"') {
if let Some(end) = hmac_line.rfind('"') {
if start < end {
return Ok((body, &hmac_line[start + 1..end]));
}
}
}
}
Err(Error::PolicyTampered(
"security.toml missing HMAC signature".to_string(),
))
}
pub(crate) mod advisory_lock {
use crate::error::Error;
use std::fs::File;
use std::path::Path;
pub fn acquire(path: &Path, exclusive: bool) -> Result<LockGuard, Error> {
let mut opts = File::options();
opts.create(true).truncate(false).read(true).write(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
opts.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE);
}
let file = opts.open(path).map_err(Error::StorageIo)?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let op = if exclusive {
libc::LOCK_EX
} else {
libc::LOCK_SH
};
let rc = unsafe { libc::flock(file.as_raw_fd(), op) };
if rc != 0 {
return Err(Error::StorageIo(std::io::Error::last_os_error()));
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK};
let flags = if exclusive {
LOCKFILE_EXCLUSIVE_LOCK
} else {
0
};
unsafe {
let handle = file.as_raw_handle();
let mut overlapped: windows_sys::Win32::System::IO::OVERLAPPED = std::mem::zeroed();
if LockFileEx(handle, flags, 0, 0xFFFF_FFFF, 0xFFFF_FFFF, &mut overlapped) == 0 {
return Err(Error::StorageIo(std::io::Error::last_os_error()));
}
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = exclusive;
}
Ok(LockGuard(file))
}
pub struct LockGuard(File);
impl Drop for LockGuard {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::UnlockFile;
unsafe {
let handle = self.0.as_raw_handle();
UnlockFile(handle, 0, 0, 0xFFFF_FFFF, 0xFFFF_FFFF);
}
}
}
}
}