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 load_system_defaults() -> SecurityConfig {
let mut config = SecurityConfig::default();
apply_system_overrides(&mut config);
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 sealed_path = security_config_sealed_path(root);
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)?;
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() {
let content = std::fs::read_to_string(&path)?;
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);
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(not(unix))]
{
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 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) {
let sys_path = Path::new("/etc/envseal/system.toml");
if !sys_path.exists() {
return;
}
if crate::guard::verify_not_symlink(sys_path).is_err() {
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let Ok(meta) = std::fs::metadata(sys_path) else {
return;
};
if meta.uid() != 0 {
return;
}
if meta.mode() & 0o022 != 0 {
return;
}
}
let Ok(content) = std::fs::read_to_string(sys_path) else {
return;
};
let Ok(sys_overrides) = toml::from_str::<SysOverride>(&content) else {
return;
};
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;
}
}
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)
.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 file = File::options()
.create(true)
.truncate(false)
.read(true)
.write(true)
.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::LockFile;
unsafe {
let handle = file.as_raw_handle();
if LockFile(handle, 0, 0, 0xFFFF_FFFF, 0xFFFF_FFFF) == 0 {
return Err(Error::StorageIo(std::io::Error::last_os_error()));
}
}
let _ = exclusive; }
#[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);
}
}
}
}
}