pub mod linux;
pub mod macos;
pub mod relay;
pub mod windows;
#[cfg(feature = "mock-gui")]
pub mod mock;
use crate::audit;
use crate::error::Error;
use crate::guard;
use crate::security_config::SecurityConfig;
use std::process::Command;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
#[cfg(target_os = "linux")]
use linux::{
linux_fido2_pin_entry, linux_passphrase, linux_popup, linux_preexec_prompt, linux_secret_value,
linux_totp_entry, resolve_linux_dialog, DialogKind,
};
#[cfg(target_os = "macos")]
use macos::{
macos_fido2_pin_entry, macos_passphrase, macos_popup, macos_preexec_prompt, macos_secret_value,
macos_totp_entry,
};
#[cfg(target_os = "windows")]
use windows::{
windows_fido2_pin_entry, windows_passphrase, windows_popup, windows_preexec_prompt,
windows_secret_value, windows_totp_entry,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Approval {
AllowOnce,
AllowAlways,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreexecChoice {
Store,
Skip,
DontAskAgain,
}
pub fn preexec_capture_prompt(message: &str) -> Result<PreexecChoice, Error> {
#[cfg(feature = "mock-gui")]
if let Some(choice) = mock::get_mock_preexec() {
return Ok(choice);
}
if !has_display() {
return Ok(PreexecChoice::Skip);
}
#[cfg(target_os = "linux")]
{
linux_preexec_prompt(message)
}
#[cfg(target_os = "macos")]
{
macos_preexec_prompt(message)
}
#[cfg(target_os = "windows")]
{
windows_preexec_prompt(message)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = message;
Ok(PreexecChoice::Skip)
}
}
static RATE_LIMITER: Mutex<RateLimiterState> = Mutex::new(RateLimiterState {
recent_requests: Vec::new(),
last_request: None,
});
struct RateLimiterState {
recent_requests: Vec<Instant>,
last_request: Option<Instant>,
}
pub fn request_approval(
binary_path: &str,
command: &[String],
secret_name: &str,
env_var: &str,
config: &SecurityConfig,
) -> Result<Approval, Error> {
#[cfg(feature = "mock-gui")]
if let Some(response) = mock::get_mock_approval() {
return response;
}
let cmd_str = command.join(" ");
enforce_rate_limit(config)?;
if config.relay_required {
match relay::request_relay_approval(config, binary_path, secret_name, env_var) {
Ok(relay::RelayDecision::Allow) => return Ok(Approval::AllowOnce),
Ok(relay::RelayDecision::Deny | relay::RelayDecision::Timeout) => {
return Err(Error::UserDenied);
}
Err(e) => {
return Err(Error::RelayRequiredButUnavailable(e.to_string()));
}
}
}
if !has_display() {
return Err(Error::NoDisplay);
}
let ctx = guard::DetectorContext::builder()
.binary_path(binary_path)
.stdin_kind(guard::detect_stdin_kind())
.build();
let signals = guard::assess_all_signals(&ctx);
let policy = config.build_policy();
let decision = guard::evaluate(&signals, &policy, config.tier);
for sig in &decision.log_entries {
audit::log(&audit::AuditEvent::SignalRecorded {
tier: format!("{:?}", config.tier),
classification: format!("{} [{}] {}", sig.severity.as_str(), sig.id, sig.label),
})
.map_err(|e| Error::AuditLogFailed(e.to_string()))?;
}
if let Some(blocking) = decision.blocking_signal.as_ref() {
return Err(Error::EnvironmentCompromised(format!(
"{label} ({id}): {detail} — {mitigation}",
label = blocking.label,
id = blocking.id,
detail = blocking.detail,
mitigation = blocking.mitigation,
)));
}
if (config.challenge_required || decision.needs_friction)
&& !challenge_already_passed_in_process()
{
challenge_gate()?;
mark_challenge_passed_in_process();
}
if config.approval_delay_secs > 0 {
std::thread::sleep(Duration::from_secs(config.approval_delay_secs.into()));
}
let mut warnings = String::new();
for warning in &decision.warnings {
warnings.push_str(warning);
warnings.push('\n');
}
#[cfg(target_os = "linux")]
{
linux_popup(binary_path, &cmd_str, secret_name, env_var, &warnings)
}
#[cfg(target_os = "macos")]
{
macos_popup(binary_path, &cmd_str, secret_name, env_var, &warnings)
}
#[cfg(target_os = "windows")]
{
windows_popup(binary_path, &cmd_str, secret_name, env_var, &warnings)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
pub fn request_passphrase(
is_new: bool,
config: &SecurityConfig,
) -> Result<Zeroizing<String>, Error> {
request_passphrase_with_hint(is_new, None, config)
}
pub fn request_passphrase_with_hint(
is_new: bool,
prev_error: Option<&str>,
_config: &SecurityConfig,
) -> Result<Zeroizing<String>, Error> {
#[cfg(feature = "mock-gui")]
if let Some(response) = mock::get_mock_passphrase() {
return Ok(response);
}
if !has_display() {
return Err(Error::NoDisplay);
}
#[cfg(target_os = "linux")]
{
linux_passphrase(is_new, prev_error)
}
#[cfg(target_os = "macos")]
{
macos_passphrase(is_new, prev_error)
}
#[cfg(target_os = "windows")]
{
windows_passphrase(is_new, prev_error)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = prev_error;
Err(Error::NoDisplay)
}
}
pub fn request_secret_value(
key_name: &str,
description: &str,
_config: &SecurityConfig,
) -> Result<Zeroizing<String>, Error> {
#[cfg(feature = "mock-gui")]
if let Some(response) = mock::get_mock_secret_value() {
return Ok(response);
}
if !has_display() {
return Err(Error::NoDisplay);
}
let safe_desc: String = description
.chars()
.filter(|c| !c.is_control())
.take(256)
.collect();
#[cfg(target_os = "linux")]
{
linux_secret_value(key_name, &safe_desc)
}
#[cfg(target_os = "macos")]
{
macos_secret_value(key_name, &safe_desc)
}
#[cfg(target_os = "windows")]
{
windows_secret_value(key_name, &safe_desc)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
pub fn request_fido2_pin(
retries_left: u32,
attempt: u32,
) -> Result<zeroize::Zeroizing<String>, Error> {
#[cfg(feature = "mock-gui")]
if let Some(response) = mock::get_mock_fido2_pin() {
return Ok(response);
}
if !has_display() {
return Err(Error::NoDisplay);
}
#[cfg(target_os = "linux")]
{
linux_fido2_pin_entry(retries_left, attempt)
}
#[cfg(target_os = "macos")]
{
macos_fido2_pin_entry(retries_left, attempt)
}
#[cfg(target_os = "windows")]
{
windows_fido2_pin_entry(retries_left, attempt)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = (retries_left, attempt);
Err(Error::NoDisplay)
}
}
pub fn request_totp_code(attempt: u32) -> Result<String, Error> {
#[cfg(feature = "mock-gui")]
if let Some(response) = mock::get_mock_totp() {
return Ok(response);
}
if !has_display() {
return Err(Error::NoDisplay);
}
#[cfg(target_os = "linux")]
{
linux_totp_entry(attempt)
}
#[cfg(target_os = "macos")]
{
macos_totp_entry(attempt)
}
#[cfg(target_os = "windows")]
{
windows_totp_entry(attempt)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
#[must_use]
pub fn is_interpreter(binary_path: &str) -> bool {
crate::guard::is_interpreter(binary_path)
}
static CHALLENGE_PASSED_THIS_PROCESS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn challenge_already_passed_in_process() -> bool {
CHALLENGE_PASSED_THIS_PROCESS.load(std::sync::atomic::Ordering::Acquire)
}
fn mark_challenge_passed_in_process() {
CHALLENGE_PASSED_THIS_PROCESS.store(true, std::sync::atomic::Ordering::Release);
}
fn challenge_gate() -> Result<(), Error> {
let (challenge_text, expected) = guard::generate_gui_challenge();
#[cfg(target_os = "linux")]
{
let (dialog_path, kind) = resolve_linux_dialog()?;
let result = match kind {
DialogKind::Zenity => Command::new(&dialog_path)
.args([
"--entry",
"--title=envseal — Security Challenge",
&format!("--text=LOCKDOWN MODE\n\n{challenge_text}\n\nThis code confirms you are physically present."),
"--width=400",
])
.output(),
DialogKind::Kdialog => Command::new(&dialog_path)
.args([
"--inputbox",
&format!("LOCKDOWN: {challenge_text}"),
"--title",
"envseal — Security Challenge",
])
.output(),
};
match result {
Ok(output) if output.status.success() => {
let answer = String::from_utf8_lossy(&output.stdout).trim().to_string();
if guard::verify_gui_challenge(&expected, &answer) {
Ok(())
} else {
Err(Error::UserDenied)
}
}
_ => Err(Error::UserDenied),
}
}
#[cfg(target_os = "macos")]
{
let script = format!(
r#"display dialog "LOCKDOWN: {challenge_text}" with title "envseal — Security Challenge" default answer "" buttons {{"Cancel", "OK"}} default button "OK""#
);
let binary = guard::verify_gui_binary("osascript")
.unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/osascript"));
let result = Command::new(&binary).args(["-e", &script]).output();
match result {
Ok(output) if output.status.success() => {
let out = String::from_utf8_lossy(&output.stdout);
if let Some(answer) = out.strip_prefix("text returned:") {
if guard::verify_gui_challenge(&expected, answer.trim()) {
return Ok(());
}
}
Err(Error::UserDenied)
}
_ => Err(Error::UserDenied),
}
}
#[cfg(target_os = "windows")]
{
let script = format!(
r#"$input = [Microsoft.VisualBasic.Interaction]::InputBox("LOCKDOWN: {challenge_text}", "envseal — Security Challenge"); Write-Output $input"#
);
let result = Command::new("powershell")
.args([
"-Command",
&format!("Add-Type -AssemblyName Microsoft.VisualBasic; {script}"),
])
.output();
match result {
Ok(output) if output.status.success() => {
let answer = String::from_utf8_lossy(&output.stdout).trim().to_string();
if guard::verify_gui_challenge(&expected, &answer) {
Ok(())
} else {
Err(Error::UserDenied)
}
}
_ => Err(Error::UserDenied),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::NoDisplay)
}
}
fn has_display() -> bool {
if std::env::var("ENVSEAL_DISABLE").is_ok_and(|v| !v.is_empty())
|| std::env::var("ENVSEAL_NO_GUI").is_ok_and(|v| !v.is_empty())
{
return false;
}
#[cfg(target_os = "linux")]
{
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok()
}
#[cfg(target_os = "macos")]
{
true }
#[cfg(target_os = "windows")]
{
std::env::var("SESSIONNAME")
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
false
}
}
fn enforce_rate_limit(config: &crate::security_config::SecurityConfig) -> Result<(), Error> {
let mut guard = RATE_LIMITER.lock().map_err(|_| {
Error::CryptoFailure("rate limiter state corrupted — retry after cooldown".to_string())
})?;
let state = &mut *guard;
let now = Instant::now();
if config.approval_cooldown_secs > 0 {
if let Some(last) = state.last_request {
let elapsed = now.duration_since(last);
if elapsed.as_secs() < u64::from(config.approval_cooldown_secs) {
let remaining = u64::from(config.approval_cooldown_secs) - elapsed.as_secs();
crate::audit::log_required(&crate::audit::AuditEvent::RateLimited {
reason: format!("cooldown: {remaining}s remaining"),
})?;
return Err(Error::CryptoFailure(format!(
"rate limited: approval cooldown ({remaining}s remaining). \
this prevents approval fatigue attacks."
)));
}
}
}
if config.max_approvals_per_minute > 0 {
let one_minute_ago = now
.checked_sub(std::time::Duration::from_secs(60))
.unwrap_or(now);
state.recent_requests.retain(|&t| t > one_minute_ago);
if state.recent_requests.len() >= config.max_approvals_per_minute as usize {
crate::audit::log_required(&crate::audit::AuditEvent::RateLimited {
reason: format!(
"per-minute cap: {} requests in last 60s (max={})",
state.recent_requests.len(),
config.max_approvals_per_minute
),
})?;
return Err(Error::CryptoFailure(format!(
"rate limited: {} approval requests in the last minute (max={}). \
this prevents approval fatigue attacks.",
state.recent_requests.len(),
config.max_approvals_per_minute
)));
}
}
state.last_request = Some(now);
state.recent_requests.push(now);
Ok(())
}