envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! On-disk persistence for [`SecurityConfig`].
//!
//! Canonical layout in 0.3.0+: `<root>/security.sealed` —
//! AES-256-GCM authenticated encryption with an HKDF-derived
//! per-domain key (`b"security_config.v1"`) off the master key.
//! AEAD provides integrity AND confidentiality; an attacker who
//! reads the file learns nothing about which tier you're on, what
//! relay endpoint you've paired with, or which signal IDs you've
//! overridden.
//!
//! Back-compat read path honors the v0.2.x plaintext-with-HMAC
//! layout (`<root>/security.toml` + `# hmac = "..."` comment); the
//! next save migrates by writing `security.sealed` and deleting the
//! plaintext.
//!
//! `/etc/envseal/system.toml` (enterprise-wide override channel)
//! stays plaintext — it's owned by root, not the user, and covers
//! force-lockdown / fleet-management knobs that are themselves
//! public policy.

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;

/// Domain string for the security config sealed blob. Must not
/// collide with any other `sealed_blob::seal` consumer.
const SECURITY_CONFIG_DOMAIN: &[u8] = b"security_config.v1";

/// Legacy plaintext path within a vault root. Read-only on the
/// migration path; writes always target [`security_config_sealed_path`].
#[must_use]
pub fn security_config_path(root: &Path) -> PathBuf {
    root.join("security.toml")
}

/// Canonical sealed-blob path within a vault root.
#[must_use]
pub fn security_config_sealed_path(root: &Path) -> PathBuf {
    root.join("security.sealed")
}

/// Load the system defaults for the vault.
///
/// Returns a baseline configuration upgraded by `/etc/envseal/system.toml` if it exists.
#[must_use]
pub fn load_system_defaults() -> SecurityConfig {
    let mut config = SecurityConfig::default();
    apply_system_overrides(&mut config);
    config
}

/// Load the security config from disk, or return default if not found.
///
/// The config file is HMAC-signed. If the signature is invalid,
/// returns `PolicyTampered` to prevent an attacker from modifying
/// the security config to downgrade protections.
pub fn load_config(root: &Path, master_key: &[u8; 32]) -> Result<SecurityConfig, Error> {
    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();

    // Try the canonical sealed file first.
    let sealed_path = security_config_sealed_path(root);
    if sealed_path.exists() {
        crate::guard::verify_not_symlink(&sealed_path)?;
        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 {
        // v0.2.x back-compat: plaintext security.toml + HMAC
        // comment. The next save_config call will migrate by
        // writing security.sealed and deleting this file.
        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}")))?;
        }
    }

    // SECURITY: If the loaded config is weaker than the system's
    // default (e.g., files were deleted by an attacker), the
    // enterprise override replay below will re-apply force_lockdown.
    // We also clamp tier so it can never silently drop below
    // Standard without explicit user action.
    if config.tier < SecurityTier::Standard {
        config.tier = SecurityTier::Standard;
    }

    // Replay enterprise-level System Configuration overrides to ensure
    // they supersede the user's local security config.
    // This is the defense against Catch-22 UI Configuration Splitting.
    apply_system_overrides(&mut config);

    Ok(config)
}

/// Persist the security config to disk as an AES-256-GCM sealed
/// blob at `<root>/security.sealed`. AEAD covers both integrity
/// (replaces HMAC) and confidentiality (defeats the v0.2.x leak
/// where reading the file revealed your tier / relay endpoint /
/// signal overrides).
///
/// Migration: if a legacy `security.toml` exists, it is removed
/// AFTER the sealed write succeeds.
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);
    // Atomic write: temp file + rename so crashes or concurrent
    // writers never leave a truncated config on disk.
    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))]
    {
        std::fs::write(&tmp_path, &blob).map_err(Error::StorageIo)?;
    }

    std::fs::rename(&tmp_path, &sealed_path)?;

    // Migration: drop the legacy plaintext now that the sealed file
    // is durably on disk. Best-effort — a permission failure here
    // doesn't lose data because the sealed file is canonical.
    let legacy_path = security_config_path(root);
    if legacy_path.exists() {
        let _ = std::fs::remove_file(&legacy_path);
    }

    Ok(())
}

/// Subset of `/etc/envseal/system.toml` we honor. Adding a field
/// here is the entire diff for a new enterprise override.
#[derive(Deserialize)]
struct SysOverride {
    force_lockdown: Option<bool>,
    custom_ui_cmd: Option<String>,
}

/// Apply the enterprise system overrides (`/etc/envseal/system.toml`)
/// onto an existing [`SecurityConfig`]. Used by both initial load and
/// the post-user-config replay so deployment-level decisions can't be
/// silently undone by a per-vault `security.toml`.
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;
    }
    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;
    }
}

/// Compute HMAC-SHA256 for content signing.
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()))
}

/// Split signed content into body and HMAC.
fn split_signed_content(content: &str) -> Result<(&str, &str), Error> {
    // HMAC is on the last line: # hmac = "..."
    if let Some(pos) = content.rfind("\n# hmac = \"") {
        let body = &content[..pos];
        let hmac_line = &content[pos..];

        // Extract the hex string between quotes
        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(),
    ))
}

/// Advisory file-locking helper — prevents lost-update races when
/// multiple envseal processes read-modify-write the security config
/// concurrently. Uses `flock` on Unix and `LockFile` on Windows.
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; // Windows LockFile is always exclusive for our range
        }

        #[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);
                }
            }
        }
    }
}