use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::hmac::{compute_hmac, split_hmac};
use crate::error::Error;
use crate::vault::sealed_blob;
const POLICY_SEAL_DOMAIN: &[u8] = b"policy.v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum RuleScope {
Key,
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub binary: String,
pub secret: String,
pub scope: RuleScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
}
impl Rule {
#[must_use]
pub fn is_expired(&self, now_unix_secs: u64) -> bool {
self.expires_at.is_some_and(|t| now_unix_secs >= t)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Policy {
#[serde(default)]
pub rules: Vec<Rule>,
#[serde(default)]
pub generation: u64,
}
impl Policy {
pub fn load(path: &Path) -> Result<Self, Error> {
if !path.exists() {
return Ok(Self::default());
}
crate::guard::verify_not_symlink(path)?;
let content = fs::read_to_string(path)?;
let (toml_content, stored_hmac) = split_hmac(&content);
if stored_hmac.is_some() {
return Err(Error::PolicyTampered(
"legacy policy.toml contains an HMAC signature — use load_sealed instead"
.to_string(),
));
}
toml::from_str(toml_content).map_err(|e| Error::PolicyParse(e.to_string()))
}
pub fn load_sealed(path: &Path, master_key: &[u8; 32]) -> Result<Self, Error> {
let lock_path = path.with_extension("lock");
crate::guard::verify_not_symlink(&lock_path)?;
let _lock = advisory_lock::acquire(&lock_path, false)?;
let sealed_path = sealed_path_for(path);
if sealed_path.exists() {
crate::guard::verify_not_symlink(&sealed_path)?;
let blob = fs::read(&sealed_path)?;
let plaintext = sealed_blob::unseal(&blob, master_key, POLICY_SEAL_DOMAIN)?;
let s = std::str::from_utf8(&plaintext)
.map_err(|e| Error::PolicyParse(format!("sealed policy not utf-8: {e}")))?;
let policy: Policy =
toml::from_str(s).map_err(|e| Error::PolicyParse(e.to_string()))?;
return Ok(policy);
}
load_verified_legacy(path, master_key)
}
pub fn load_verified(path: &Path, master_key: &[u8; 32]) -> Result<Self, Error> {
Self::load_sealed(path, master_key)
}
pub fn save(&self, path: &Path) -> Result<(), Error> {
let content =
toml::to_string_pretty(self).map_err(|e| Error::PolicyParse(e.to_string()))?;
fs::write(path, content)?;
set_file_permissions(path)?;
Ok(())
}
pub fn save_sealed(&self, path: &Path, master_key: &[u8; 32]) -> Result<(), Error> {
let lock_path = path.with_extension("lock");
crate::guard::verify_not_symlink(&lock_path)?;
let _lock = advisory_lock::acquire(&lock_path, true)?;
let mut to_save = self.clone();
let _pruned = to_save.prune_expired();
to_save.generation = to_save.generation.saturating_add(1);
let raw =
toml::to_string_pretty(&to_save).map_err(|e| Error::PolicyParse(e.to_string()))?;
let blob = sealed_blob::seal(raw.as_bytes(), master_key, POLICY_SEAL_DOMAIN)?;
let sealed_path = sealed_path_for(path);
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))]
{
fs::write(&tmp_path, &blob).map_err(Error::StorageIo)?;
set_file_permissions(&tmp_path)?;
}
fs::rename(&tmp_path, &sealed_path)?;
if path.exists() && path != sealed_path {
let _ = fs::remove_file(path);
}
Ok(())
}
pub fn save_signed(&self, path: &Path, master_key: &[u8; 32]) -> Result<(), Error> {
self.save_sealed(path, master_key)
}
#[must_use]
pub fn is_authorized(&self, binary_path: &str, secret_name: &str) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
self.rules
.iter()
.any(|rule| !rule.is_expired(now) && rule_matches(rule, binary_path, secret_name, None))
}
#[must_use]
pub fn is_authorized_with_hash_and_args(
&self,
binary_path: &str,
secret_name: &str,
args_fingerprint: Option<&str>,
current_hash: Option<&str>,
) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
self.rules.iter().any(|rule| {
if rule.is_expired(now) {
return false;
}
if !rule_matches(rule, binary_path, secret_name, current_hash) {
return false;
}
match (rule.args_fingerprint.as_deref(), args_fingerprint) {
(Some(stored), Some(fp)) => stored == fp,
(None, None) => true,
_ => false,
}
})
}
#[must_use]
pub fn is_authorized_with_args(
&self,
binary_path: &str,
secret_name: &str,
args_fingerprint: Option<&str>,
) -> bool {
self.is_authorized_with_hash_and_args(binary_path, secret_name, args_fingerprint, None)
}
pub fn prune_expired(&mut self) -> usize {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let before = self.rules.len();
self.rules.retain(|r| !r.is_expired(now));
before - self.rules.len()
}
#[must_use]
pub fn binary_hash(&self, binary_path: &str) -> Option<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
self.rules
.iter()
.find(|r| r.binary == binary_path && r.binary_hash.is_some() && !r.is_expired(now))
.and_then(|r| r.binary_hash.clone())
}
pub fn allow_key(&mut self, binary_path: &str, secret_name: &str) {
self.allow_key_with_hash(binary_path, secret_name, "");
}
pub fn allow_key_with_hash(&mut self, binary_path: &str, secret_name: &str, hash: &str) {
self.allow_key_with_hash_and_args(binary_path, secret_name, hash, None);
}
pub fn allow_key_with_hash_and_args(
&mut self,
binary_path: &str,
secret_name: &str,
hash: &str,
args_fingerprint: Option<String>,
) {
if self.is_authorized_with_hash_and_args(
binary_path,
secret_name,
args_fingerprint.as_deref(),
Some(hash).filter(|h| !h.is_empty()),
) {
return;
}
let binary_hash = if hash.is_empty() {
None
} else {
Some(hash.to_string())
};
self.rules.push(Rule {
binary: binary_path.to_string(),
secret: secret_name.to_string(),
scope: RuleScope::Key,
binary_hash,
args_fingerprint,
expires_at: None,
});
}
pub fn allow_key_with_expiry(
&mut self,
binary_path: &str,
secret_name: &str,
hash: &str,
args_fingerprint: Option<String>,
expires_at_unix_secs: u64,
) {
if self.is_authorized_with_hash_and_args(
binary_path,
secret_name,
args_fingerprint.as_deref(),
Some(hash).filter(|h| !h.is_empty()),
) {
return;
}
let binary_hash = if hash.is_empty() {
None
} else {
Some(hash.to_string())
};
self.rules.push(Rule {
binary: binary_path.to_string(),
secret: secret_name.to_string(),
scope: RuleScope::Key,
binary_hash,
args_fingerprint,
expires_at: Some(expires_at_unix_secs),
});
}
pub fn allow_all(&mut self, binary_path: &str) {
self.allow_all_with_hash(binary_path, "");
}
pub fn allow_all_with_hash(&mut self, binary_path: &str, hash: &str) {
self.rules
.retain(|r| !(r.binary == binary_path && r.scope == RuleScope::Key));
let has_all = self
.rules
.iter()
.any(|r| r.binary == binary_path && r.scope == RuleScope::All);
if !has_all {
let binary_hash = if hash.is_empty() {
None
} else {
Some(hash.to_string())
};
self.rules.push(Rule {
binary: binary_path.to_string(),
secret: "*".to_string(),
scope: RuleScope::All,
binary_hash,
args_fingerprint: None,
expires_at: None,
});
}
}
pub fn revoke_binary(&mut self, binary_path: &str) {
self.rules.retain(|r| r.binary != binary_path);
}
pub fn revoke_secret(&mut self, secret_name: &str) {
self.rules
.retain(|r| !(r.scope == RuleScope::Key && r.secret == secret_name));
}
}
fn rule_matches(
rule: &Rule,
binary_path: &str,
secret_name: &str,
current_hash: Option<&str>,
) -> bool {
if rule.binary != binary_path {
return false;
}
if rule.scope != RuleScope::All && rule.scope == RuleScope::Key && rule.secret != secret_name {
return false;
}
if let (Some(stored), Some(current)) = (rule.binary_hash.as_deref(), current_hash) {
return stored == current;
}
if current_hash.is_some() {
return false;
}
true
}
pub fn resolve_binary(cmd: &str) -> Result<String, Error> {
if std::path::Path::new(cmd).is_absolute() {
if std::path::Path::new(cmd).exists() {
return Ok(cmd.to_string());
}
return Err(Error::BinaryResolution(format!(
"binary does not exist: {cmd}"
)));
}
let path_var = std::env::var("PATH").unwrap_or_default();
let separator = if cfg!(target_os = "windows") {
';'
} else {
':'
};
let suffix_candidates: Vec<String> = if cfg!(target_os = "windows") {
let mut v = vec![String::new()];
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_string());
let already_has_ext = std::path::Path::new(cmd).extension().is_some();
if !already_has_ext {
for ext in pathext.split(';') {
let ext = ext.trim();
if ext.is_empty() {
continue;
}
v.push(ext.to_string());
}
}
v
} else {
vec![String::new()]
};
for dir in path_var.split(separator) {
let dir_path = std::path::Path::new(dir);
if dir.is_empty() || !dir_path.is_absolute() {
continue;
}
for suffix in &suffix_candidates {
let name = if suffix.is_empty() {
cmd.to_string()
} else {
format!("{cmd}{suffix}")
};
let candidate = dir_path.join(&name);
if candidate.exists() {
if !is_executable(&candidate) {
continue;
}
return candidate
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| {
Error::BinaryResolution(format!("cannot canonicalize {cmd}: {e}"))
});
}
}
}
Err(Error::BinaryResolution(format!(
"binary not found in PATH: {cmd}"
)))
}
fn is_executable(path: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
let Some(ext) = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_uppercase)
else {
return false;
};
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_string());
if pathext.trim().is_empty() {
return std::fs::metadata(path).is_ok_and(|m| m.is_file());
}
let needle = format!(".{ext}");
pathext
.split(';')
.any(|entry| entry.eq_ignore_ascii_case(&needle))
}
#[cfg(not(any(unix, windows)))]
{
std::fs::metadata(path).is_ok_and(|m| m.is_file())
}
}
#[must_use]
pub fn sealed_path_for(legacy_path: &Path) -> PathBuf {
legacy_path.with_extension("sealed")
}
fn load_verified_legacy(path: &Path, master_key: &[u8; 32]) -> Result<Policy, Error> {
if !path.exists() {
return Ok(Policy::default());
}
crate::guard::verify_not_symlink(path)?;
let content = fs::read_to_string(path)?;
let (toml_content, stored_hmac) = split_hmac(&content);
let stored_hmac = stored_hmac.ok_or_else(|| {
Error::PolicyTampered(
"policy.toml has no HMAC signature — it may have been \
created or modified outside envseal."
.to_string(),
)
})?;
let computed = compute_hmac(toml_content.as_bytes(), master_key)?;
if !crate::guard::constant_time_eq(computed.as_bytes(), stored_hmac.as_bytes()) {
return Err(Error::PolicyTampered(
"policy.toml HMAC mismatch".to_string(),
));
}
toml::from_str(toml_content).map_err(|e| Error::PolicyParse(e.to_string()))
}
fn set_file_permissions(path: &Path) -> Result<(), Error> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
}
#[cfg(windows)]
{
crate::policy::windows_acl::set_owner_only_dacl(path)?;
}
Ok(())
}
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);
}
}
}
}
}