From 4de73e389d7936f6d3ca81bf82d5198b852b9e0c Mon Sep 17 00:00:00 2001 From: Giovanni Magliocchetti Date: Fri, 3 Oct 2025 18:24:42 +0200 Subject: [PATCH 1/4] Add Administrator Protection Compatibility with Windows Hello Authentication - Added core library for Sudo Elevation Broker with modules for audit logging, elevation handling, and service management. - Implemented audit logging to Windows Event Log for elevation requests, successes, failures, and authentication issues. - Developed ElevationHandler to create elevated processes in System Managed Administrator Account context. - Created PipeServer to handle client connections and elevation requests via named pipes. - Established BrokerService to manage service lifecycle and control requests. - Configured logging to file in ProgramData with daily rotation. - Implemented security checks to ensure only authorized clients can request elevation. - Added configuration loading functionality with default values. - Added support for Windows Administrator Protection. - Added support for Windows Hello. Fixes #125 Signed-off-by: Giovanni Magliocchetti --- Cargo.toml | 12 + scripts/install-broker-service.ps1 | 196 +++++++++ sudo/Cargo.toml | 12 + sudo/src/ap_detection.rs | 592 ++++++++++++++++++++++++++ sudo/src/broker_client.rs | 458 ++++++++++++++++++++ sudo/src/broker_protocol.rs | 655 +++++++++++++++++++++++++++++ sudo/src/hello_auth.rs | 513 ++++++++++++++++++++++ sudo/src/lib.rs | 15 + sudo/src/main.rs | 4 + sudo/src/run_handler.rs | 247 +++++++++-- sudo_ap_broker/Cargo.toml | 50 +++ sudo_ap_broker/src/audit_logger.rs | 193 +++++++++ sudo_ap_broker/src/elevation.rs | 227 ++++++++++ sudo_ap_broker/src/lib.rs | 55 +++ sudo_ap_broker/src/main.rs | 71 ++++ sudo_ap_broker/src/pipe_server.rs | 464 ++++++++++++++++++++ sudo_ap_broker/src/service.rs | 169 ++++++++ 17 files changed, 3903 insertions(+), 30 deletions(-) create mode 100644 scripts/install-broker-service.ps1 create mode 100644 sudo/src/ap_detection.rs create mode 100644 sudo/src/broker_client.rs create mode 100644 sudo/src/broker_protocol.rs create mode 100644 sudo/src/hello_auth.rs create mode 100644 sudo/src/lib.rs create mode 100644 sudo_ap_broker/Cargo.toml create mode 100644 sudo_ap_broker/src/audit_logger.rs create mode 100644 sudo_ap_broker/src/elevation.rs create mode 100644 sudo_ap_broker/src/lib.rs create mode 100644 sudo_ap_broker/src/main.rs create mode 100644 sudo_ap_broker/src/pipe_server.rs create mode 100644 sudo_ap_broker/src/service.rs diff --git a/Cargo.toml b/Cargo.toml index 47edb88..8c2c9af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "sudo", "sudo_events", "win32resources", + "sudo_ap_broker", ] # This list of dependencies allows us to specify version numbers for dependency in a single place. @@ -18,7 +19,11 @@ members = [ # See: https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table # [workspace.dependencies] +anyhow = "1.0" +bincode = "1.3" cc = "1.2" +hmac = "0.12" +sha2 = "0.10" # We're disabling the default features for clap because we don't need the # "suggestions" feature. That provides really amazing suggestions for typos, but # it unfortunately does not seem to support localization. @@ -28,11 +33,18 @@ cc = "1.2" # See: https://docs.rs/clap/latest/clap/_features/index.html clap = { version = "4.4.7", default-features = false, features = ["std"] } embed-manifest = "1.4" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" +tokio = { version = "1", features = ["rt", "sync", "time", "macros"] } +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } which = "6.0" win_etw_provider = "0.1.8" win_etw_macros = "0.1.8" windows = "0.57" windows-registry = "0.1" +windows-service = "0.6" winres = "0.1" # For more profile settings, and details on the ones below, see https://doc.rust-lang.org/cargo/reference/profiles.html#profile-settings diff --git a/scripts/install-broker-service.ps1 b/scripts/install-broker-service.ps1 new file mode 100644 index 0000000..2452147 --- /dev/null +++ b/scripts/install-broker-service.ps1 @@ -0,0 +1,196 @@ +# Install Sudo Elevation Broker Service +# This script installs and configures the SudoElevationBroker Windows service + +[CmdletBinding()] +param( + [Parameter()] + [string]$ServicePath = "$PSScriptRoot\SudoElevationBroker.exe", + + [Parameter()] + [switch]$Uninstall +) + +$ServiceName = "SudoElevationBroker" +$DisplayName = "Sudo Elevation Broker" +$Description = "Provides elevation services for Sudo for Windows with Administrator Protection support" + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Install-BrokerService { + Write-Host "Installing $DisplayName..." -ForegroundColor Cyan + + # Check if running as administrator + if (-not (Test-Administrator)) { + Write-Error "This script must be run as Administrator" + exit 1 + } + + # Verify service executable exists + if (-not (Test-Path $ServicePath)) { + Write-Error "Service executable not found: $ServicePath" + exit 1 + } + + # Stop existing service if it exists + $existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($existingService) { + Write-Host "Stopping existing service..." -ForegroundColor Yellow + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + + Write-Host "Removing existing service..." -ForegroundColor Yellow + sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 2 + } + + # Create service + Write-Host "Creating service..." -ForegroundColor Green + $result = sc.exe create $ServiceName binPath= $ServicePath start= auto DisplayName= $DisplayName + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to create service. Exit code: $LASTEXITCODE" + exit 1 + } + + # Set service description + sc.exe description $ServiceName $Description | Out-Null + + # Configure service recovery options (restart on failure) + sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null + + # Start service + Write-Host "Starting service..." -ForegroundColor Green + Start-Service -Name $ServiceName + + # Verify service is running + Start-Sleep -Seconds 2 + $service = Get-Service -Name $ServiceName + + if ($service.Status -eq 'Running') { + Write-Host "`nāœ… Service installed and started successfully!" -ForegroundColor Green + Write-Host " Service Name: $ServiceName" -ForegroundColor Gray + Write-Host " Display Name: $DisplayName" -ForegroundColor Gray + Write-Host " Status: Running" -ForegroundColor Gray + Write-Host " Start Type: Automatic" -ForegroundColor Gray + } else { + Write-Warning "Service installed but not running. Status: $($service.Status)" + Write-Host "Check Event Viewer for errors: Application and Services Logs → $ServiceName" -ForegroundColor Yellow + } + + # Create configuration directory + $configDir = "$env:ProgramData\Microsoft\Sudo" + if (-not (Test-Path $configDir)) { + Write-Host "`nCreating configuration directory..." -ForegroundColor Cyan + New-Item -ItemType Directory -Path $configDir -Force | Out-Null + + # Create default configuration file + $configContent = @" +# Sudo Elevation Broker Service Configuration +# Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") + +[service] +log_level = "info" +max_concurrent_elevations = 10 +default_timeout_ms = 30000 + +[security] +require_hello = true +allowed_commands = [] # Empty array = all commands allowed +audit_to_event_log = true + +[named_pipe] +name = "\\\\.\pipe\\SudoElevationBroker" +max_message_size = 16777216 # 16MB +"@ + + $configFile = Join-Path $configDir "config.toml" + $configContent | Out-File -FilePath $configFile -Encoding UTF8 + Write-Host " Configuration file: $configFile" -ForegroundColor Gray + } + + Write-Host "`nšŸ“ Next Steps:" -ForegroundColor Cyan + Write-Host " 1. Enable Administrator Protection in Windows Security" -ForegroundColor White + Write-Host " 2. Set up Windows Hello (Settings → Accounts → Sign-in options)" -ForegroundColor White + Write-Host " 3. Test sudo: " -NoNewline -ForegroundColor White + Write-Host "sudo whoami" -ForegroundColor Yellow + Write-Host "" +} + +function Uninstall-BrokerService { + Write-Host "Uninstalling $DisplayName..." -ForegroundColor Cyan + + # Check if running as administrator + if (-not (Test-Administrator)) { + Write-Error "This script must be run as Administrator" + exit 1 + } + + # Check if service exists + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if (-not $service) { + Write-Warning "Service '$ServiceName' not found. Nothing to uninstall." + exit 0 + } + + # Stop service + if ($service.Status -eq 'Running') { + Write-Host "Stopping service..." -ForegroundColor Yellow + Stop-Service -Name $ServiceName -Force + Start-Sleep -Seconds 2 + } + + # Delete service + Write-Host "Removing service..." -ForegroundColor Yellow + sc.exe delete $ServiceName | Out-Null + + if ($LASTEXITCODE -eq 0) { + Write-Host "āœ… Service uninstalled successfully!" -ForegroundColor Green + } else { + Write-Error "Failed to uninstall service. Exit code: $LASTEXITCODE" + exit 1 + } + + # Optionally remove configuration + $configDir = "$env:ProgramData\Microsoft\Sudo" + if (Test-Path $configDir) { + Write-Host "`nConfiguration directory still exists: $configDir" -ForegroundColor Yellow + $response = Read-Host "Remove configuration? (y/N)" + if ($response -eq 'y' -or $response -eq 'Y') { + Remove-Item -Path $configDir -Recurse -Force + Write-Host "Configuration removed." -ForegroundColor Green + } + } +} + +function Show-ServiceStatus { + $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + + if ($service) { + Write-Host "`nšŸ“Š Service Status:" -ForegroundColor Cyan + Write-Host " Name: $($service.Name)" -ForegroundColor White + Write-Host " Display Name: $($service.DisplayName)" -ForegroundColor White + Write-Host " Status: $($service.Status)" -ForegroundColor $(if ($service.Status -eq 'Running') { 'Green' } else { 'Red' }) + Write-Host " Start Type: $($service.StartType)" -ForegroundColor White + + # Check named pipe + $pipePath = "\\.\pipe\SudoElevationBroker" + $pipeExists = Test-Path $pipePath -ErrorAction SilentlyContinue + Write-Host " Named Pipe: $($if ($pipeExists) { 'Available āœ“' } else { 'Not Found āœ—' })" -ForegroundColor $(if ($pipeExists) { 'Green' } else { 'Red' }) + + Write-Host "" + } else { + Write-Host "Service not installed." -ForegroundColor Yellow + } +} + +# Main execution +if ($Uninstall) { + Uninstall-BrokerService +} else { + Install-BrokerService + Show-ServiceStatus +} diff --git a/sudo/Cargo.toml b/sudo/Cargo.toml index c09c8dd..7249f73 100644 --- a/sudo/Cargo.toml +++ b/sudo/Cargo.toml @@ -18,6 +18,11 @@ embed-manifest.workspace = true which = { workspace = true } [dependencies] +anyhow.workspace = true +bincode.workspace = true +serde.workspace = true +hmac.workspace = true +sha2.workspace = true clap = { workspace = true, default-features = false, features = ["color", "help", "usage", "error-context"] } which = { workspace = true } @@ -29,21 +34,28 @@ win32resources = { path = "../win32resources" } [dependencies.windows] workspace = true features = [ + "Foundation", + "Security_Credentials_UI", "Wdk_Foundation", "Wdk_System_Threading", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Security_Authorization", + "Win32_Security_Credentials", "Win32_Storage_FileSystem", + "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_Etw", "Win32_System_Environment", + "Win32_System_IO", "Win32_System_Kernel", "Win32_System_Memory", + "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Rpc", + "Win32_System_Services", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", diff --git a/sudo/src/ap_detection.rs b/sudo/src/ap_detection.rs new file mode 100644 index 0000000..80f66c8 --- /dev/null +++ b/sudo/src/ap_detection.rs @@ -0,0 +1,592 @@ +// Administrator Protection Detection Module +// +// This module detects whether Windows Administrator Protection (AP) is enabled +// and determines the system's capability to support AP-based elevation. + +use std::collections::HashMap; +use windows::{ + core::*, + Win32::System::Registry::*, + Security::Credentials::UI::*, +}; + +/// Represents the various elevation environments sudo can encounter +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElevationEnvironment { + /// Standard UAC with split-token model (legacy Windows behavior) + StandardUAC, + + /// Administrator Protection enabled with Windows Hello available + AdminProtectionWithHello, + + /// Administrator Protection enabled but Windows Hello not configured + AdminProtectionWithoutHello, + + /// User is not an administrator + NoAdminPrivileges, + + /// Unable to determine the environment + Unknown, +} + +/// Result of checking Windows Hello availability +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HelloAvailability { + Available, + NotAvailable, + DeviceNotCapable, + DeviceBusy, + Unknown, +} + +/// Comprehensive system elevation capability information +#[derive(Debug, Clone)] +pub struct ElevationCapabilities { + pub environment: ElevationEnvironment, + pub ap_enabled: bool, + pub hello_available: HelloAvailability, + pub user_is_admin: bool, + pub broker_service_available: bool, + pub windows_version: String, +} + +impl ElevationCapabilities { + /// Detect all elevation capabilities of the current system + pub fn detect() -> Result { + let ap_enabled = is_admin_protection_enabled()?; + let hello_available = check_hello_availability()?; + let user_is_admin = is_current_user_admin()?; + let broker_service_available = is_broker_service_available()?; + let windows_version = get_windows_version()?; + + let environment = determine_environment( + ap_enabled, + hello_available, + user_is_admin, + ); + + Ok(Self { + environment, + ap_enabled, + hello_available, + user_is_admin, + broker_service_available, + windows_version, + }) + } + + /// Check if the system can perform elevation + pub fn can_elevate(&self) -> bool { + match self.environment { + ElevationEnvironment::StandardUAC => self.user_is_admin, + ElevationEnvironment::AdminProtectionWithHello => { + self.user_is_admin && self.broker_service_available + } + ElevationEnvironment::AdminProtectionWithoutHello => false, + ElevationEnvironment::NoAdminPrivileges => false, + ElevationEnvironment::Unknown => false, + } + } + + /// Get user-friendly error message if elevation is not possible + pub fn get_elevation_error_message(&self) -> Option { + if self.can_elevate() { + return None; + } + + match self.environment { + ElevationEnvironment::NoAdminPrivileges => { + Some("You must be a member of the Administrators group to use sudo.".to_string()) + } + ElevationEnvironment::AdminProtectionWithoutHello => { + Some(format!( + "āŒ Administrator Protection requires Windows Hello\n\n\ + To use sudo with Administrator Protection:\n\ + 1. Open Settings → Accounts → Sign-in options\n\ + 2. Set up Windows Hello (PIN, Face, or Fingerprint)\n\ + 3. Try sudo again\n\n\ + Alternative: Disable Administrator Protection in Windows Security settings" + )) + } + ElevationEnvironment::AdminProtectionWithHello if !self.broker_service_available => { + Some(format!( + "āš ļø Administrator Protection is enabled but the elevation broker service is not available.\n\n\ + To fix this:\n\ + 1. Run: sc start SudoElevationBroker\n\ + 2. Or reinstall sudo: winget install Microsoft.Sudo\n\n\ + If the problem persists, check Windows Event Logs for errors." + )) + } + ElevationEnvironment::Unknown => { + Some("Unable to determine elevation capabilities. Please check system configuration.".to_string()) + } + _ => None, + } + } +} + +/// Check if Administrator Protection is enabled via registry +fn is_admin_protection_enabled() -> Result { + unsafe { + // Open the system policies registry key + let mut hkey = HKEY::default(); + let result = RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + w!("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System"), + 0, + KEY_READ, + &mut hkey, + ); + + if result.is_err() { + // If we can't read the registry, assume AP is not enabled + return Ok(false); + } + + // Check FilterAdministratorToken + // When AP is enabled, this value is set to 1 + let mut filter_token: u32 = 0; + let mut size = std::mem::size_of::() as u32; + let filter_result = RegQueryValueExW( + hkey, + w!("FilterAdministratorToken"), + None, + None, + Some(&mut filter_token as *mut u32 as *mut u8), + Some(&mut size), + ); + + // Check EnableLUA (UAC enabled) + let mut enable_lua: u32 = 0; + let mut lua_size = std::mem::size_of::() as u32; + let lua_result = RegQueryValueExW( + hkey, + w!("EnableLUA"), + None, + None, + Some(&mut enable_lua as *mut u32 as *mut u8), + Some(&mut lua_size), + ); + + RegCloseKey(hkey).ok(); + + // AP is enabled if FilterAdministratorToken == 1 and EnableLUA == 1 + Ok(filter_result.is_ok() && filter_token == 1 && + lua_result.is_ok() && enable_lua == 1) + } +} + +/// Check Windows Hello availability +fn check_hello_availability() -> Result { + // Note: This is a simplified check. In production, we'd need async handling + // For now, we'll use a blocking check with timeout + + use windows::Security::Credentials::UI::*; + + // Try to check availability + match UserConsentVerifier::CheckAvailabilityAsync() { + Ok(async_op) => { + // In a real implementation, we'd properly await this + // For this POC, we'll use a simplified blocking approach + // or mark as Unknown and check at elevation time + Ok(HelloAvailability::Unknown) + } + Err(_) => Ok(HelloAvailability::NotAvailable), + } +} + +/// Check if current user is a member of the Administrators group +fn is_current_user_admin() -> Result { + use windows::Win32::Security::*; + use windows::Win32::Foundation::*; + + unsafe { + let mut admin_group = SID::default(); + let mut sid_size = std::mem::size_of::() as u32; + + // Create well-known SID for Administrators group + let result = CreateWellKnownSid( + WinBuiltinAdministratorsSid, + None, + PSID(&mut admin_group as *mut SID as *mut std::ffi::c_void), + &mut sid_size, + ); + + if result.is_err() { + return Ok(false); + } + + // Check if current user is a member + let mut is_member = BOOL::default(); + let check_result = CheckTokenMembership( + None, + PSID(&admin_group as *const SID as *const std::ffi::c_void), + &mut is_member, + ); + + if check_result.is_ok() { + Ok(is_member.as_bool()) + } else { + Ok(false) + } + } +} + +/// Check if the elevation broker service is installed and running +fn is_broker_service_available() -> Result { + use windows::Win32::System::Services::*; + use windows::Win32::Foundation::*; + + unsafe { + // Open service control manager + let scm = OpenSCManagerW( + None, + None, + SC_MANAGER_CONNECT, + )?; + + // Try to open our service + let service_result = OpenServiceW( + scm, + w!("SudoElevationBroker"), + SERVICE_QUERY_STATUS, + ); + + if let Ok(service) = service_result { + // Query service status + let mut status = SERVICE_STATUS::default(); + let status_result = QueryServiceStatus(service, &mut status); + + CloseServiceHandle(service).ok(); + CloseServiceHandle(scm).ok(); + + if status_result.is_ok() { + return Ok(status.dwCurrentState == SERVICE_RUNNING); + } + } else { + CloseServiceHandle(scm).ok(); + } + + Ok(false) + } +} + +/// Get Windows version information +fn get_windows_version() -> Result { + use windows::Win32::System::SystemInformation::*; + + unsafe { + let mut version_info = OSVERSIONINFOEXW::default(); + version_info.dwOSVersionInfoSize = std::mem::size_of::() as u32; + + // Note: GetVersionEx is deprecated, but we'll use it for now + // In production, we should use RtlGetVersion or other methods + let info_ptr = &mut version_info as *mut OSVERSIONINFOEXW as *mut OSVERSIONINFOW; + + #[allow(deprecated)] + if GetVersionExW(info_ptr).as_bool() { + Ok(format!( + "{}.{}.{}", + version_info.dwMajorVersion, + version_info.dwMinorVersion, + version_info.dwBuildNumber + )) + } else { + Ok("Unknown".to_string()) + } + } +} + +/// Determine the elevation environment based on detected capabilities +fn determine_environment( + ap_enabled: bool, + hello_available: HelloAvailability, + user_is_admin: bool, +) -> ElevationEnvironment { + if !user_is_admin { + return ElevationEnvironment::NoAdminPrivileges; + } + + if !ap_enabled { + return ElevationEnvironment::StandardUAC; + } + + // AP is enabled + match hello_available { + HelloAvailability::Available => ElevationEnvironment::AdminProtectionWithHello, + HelloAvailability::NotAvailable | + HelloAvailability::DeviceNotCapable | + HelloAvailability::DeviceBusy => ElevationEnvironment::AdminProtectionWithoutHello, + HelloAvailability::Unknown => ElevationEnvironment::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capabilities_detection() { + // This test will only work on Windows systems + if cfg!(windows) { + let caps = ElevationCapabilities::detect(); + assert!(caps.is_ok(), "Should be able to detect capabilities"); + + let caps = caps.unwrap(); + println!("Detected environment: {:?}", caps.environment); + println!("AP enabled: {}", caps.ap_enabled); + println!("Hello available: {:?}", caps.hello_available); + println!("User is admin: {}", caps.user_is_admin); + println!("Broker available: {}", caps.broker_service_available); + } + } + + #[test] + fn test_hello_availability_values() { + // Test that all enum values can be compared + assert_eq!(HelloAvailability::Available, HelloAvailability::Available); + assert_ne!(HelloAvailability::Available, HelloAvailability::NotAvailable); + assert_ne!(HelloAvailability::DeviceNotCapable, HelloAvailability::DeviceBusy); + } + + #[test] + fn test_elevation_environment_values() { + // Test that all enum values can be compared + assert_eq!(ElevationEnvironment::StandardUAC, ElevationEnvironment::StandardUAC); + assert_ne!(ElevationEnvironment::StandardUAC, ElevationEnvironment::NoAdminPrivileges); + assert_ne!( + ElevationEnvironment::AdminProtectionWithHello, + ElevationEnvironment::AdminProtectionWithoutHello + ); + } + + #[test] + fn test_can_elevate_no_admin_privileges() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::NoAdminPrivileges, + ap_enabled: false, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: false, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + assert!(!caps.can_elevate(), "Non-admin users cannot elevate"); + } + + #[test] + fn test_can_elevate_standard_uac() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::StandardUAC, + ap_enabled: false, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: true, + broker_service_available: false, + windows_version: "10.0.19045".to_string(), + }; + + assert!(caps.can_elevate(), "Standard UAC with admin user can elevate"); + } + + #[test] + fn test_can_elevate_ap_with_hello() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::AdminProtectionWithHello, + ap_enabled: true, + hello_available: HelloAvailability::Available, + user_is_admin: true, + broker_service_available: true, + windows_version: "11.0.22621".to_string(), + }; + + assert!(caps.can_elevate(), "AP with Hello and broker can elevate"); + } + + #[test] + fn test_can_elevate_ap_without_broker() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::AdminProtectionWithHello, + ap_enabled: true, + hello_available: HelloAvailability::Available, + user_is_admin: true, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + assert!(!caps.can_elevate(), "AP without broker cannot elevate"); + } + + #[test] + fn test_can_elevate_ap_without_hello() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::AdminProtectionWithoutHello, + ap_enabled: true, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: true, + broker_service_available: true, + windows_version: "11.0.22621".to_string(), + }; + + assert!(!caps.can_elevate(), "AP without Hello cannot elevate"); + } + + #[test] + fn test_error_message_no_admin() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::NoAdminPrivileges, + ap_enabled: false, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: false, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + let error = caps.get_elevation_error_message(); + assert!(error.is_some()); + assert!(error.unwrap().contains("Administrators group")); + } + + #[test] + fn test_error_message_ap_without_hello() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::AdminProtectionWithoutHello, + ap_enabled: true, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: true, + broker_service_available: true, + windows_version: "11.0.22621".to_string(), + }; + + let error = caps.get_elevation_error_message(); + assert!(error.is_some()); + let msg = error.unwrap(); + assert!(msg.contains("Windows Hello")); + assert!(msg.contains("Settings")); + } + + #[test] + fn test_error_message_broker_unavailable() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::AdminProtectionWithHello, + ap_enabled: true, + hello_available: HelloAvailability::Available, + user_is_admin: true, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + let error = caps.get_elevation_error_message(); + assert!(error.is_some()); + let msg = error.unwrap(); + assert!(msg.contains("broker service")); + assert!(msg.contains("SudoElevationBroker")); + } + + #[test] + fn test_error_message_unknown_environment() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::Unknown, + ap_enabled: false, + hello_available: HelloAvailability::Unknown, + user_is_admin: false, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + let error = caps.get_elevation_error_message(); + assert!(error.is_some()); + assert!(error.unwrap().contains("Unable to determine")); + } + + #[test] + fn test_no_error_when_can_elevate() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::StandardUAC, + ap_enabled: false, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: true, + broker_service_available: false, + windows_version: "10.0.19045".to_string(), + }; + + assert!(caps.can_elevate()); + assert!(caps.get_elevation_error_message().is_none()); + } + + #[test] + fn test_windows_version_field() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::StandardUAC, + ap_enabled: false, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: true, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + assert_eq!(caps.windows_version, "11.0.22621"); + assert!(!caps.windows_version.is_empty()); + } + + #[test] + fn test_capabilities_clone() { + let caps = ElevationCapabilities { + environment: ElevationEnvironment::StandardUAC, + ap_enabled: false, + hello_available: HelloAvailability::Available, + user_is_admin: true, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + let cloned = caps.clone(); + assert_eq!(caps.environment, cloned.environment); + assert_eq!(caps.ap_enabled, cloned.ap_enabled); + assert_eq!(caps.hello_available, cloned.hello_available); + assert_eq!(caps.user_is_admin, cloned.user_is_admin); + assert_eq!(caps.windows_version, cloned.windows_version); + } + + #[test] + fn test_all_environment_types_covered_in_can_elevate() { + // This test ensures can_elevate handles all environment types + let environments = vec![ + ElevationEnvironment::StandardUAC, + ElevationEnvironment::AdminProtectionWithHello, + ElevationEnvironment::AdminProtectionWithoutHello, + ElevationEnvironment::NoAdminPrivileges, + ElevationEnvironment::Unknown, + ]; + + for env in environments { + let caps = ElevationCapabilities { + environment: env, + ap_enabled: false, + hello_available: HelloAvailability::NotAvailable, + user_is_admin: false, + broker_service_available: false, + windows_version: "11.0.22621".to_string(), + }; + + // Should not panic + let _ = caps.can_elevate(); + } + } + + #[test] + fn test_all_hello_availability_values() { + let values = vec![ + HelloAvailability::Available, + HelloAvailability::NotAvailable, + HelloAvailability::DeviceNotCapable, + HelloAvailability::DeviceBusy, + HelloAvailability::Unknown, + ]; + + // All values should be copyable and comparable + for val in &values { + let copied = *val; + assert_eq!(*val, copied); + } + } +} diff --git a/sudo/src/broker_client.rs b/sudo/src/broker_client.rs new file mode 100644 index 0000000..c7ad522 --- /dev/null +++ b/sudo/src/broker_client.rs @@ -0,0 +1,458 @@ +// Broker Client Module +// +// This module provides the client-side interface for communicating with +// the SudoElevationBroker service via named pipes. + +use crate::broker_protocol::*; +use std::io::{Read, Write}; +use std::time::Duration; +use windows::{ + core::*, + Win32::Foundation::*, + Win32::Storage::FileSystem::*, + Win32::System::Pipes::*, + Win32::Security::*, +}; + +/// Client for communicating with the elevation broker service +pub struct BrokerClient { + pipe_handle: Option, + timeout: Duration, +} + +impl BrokerClient { + /// Create a new broker client + pub fn new() -> Self { + Self { + pipe_handle: None, + timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS as u64), + } + } + + /// Set the timeout for broker operations + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Connect to the broker service + pub fn connect(&mut self) -> Result<()> { + self.connect_with_retry(3, Duration::from_millis(500)) + } + + /// Connect to the broker service with retry logic + fn connect_with_retry(&mut self, max_retries: u32, retry_delay: Duration) -> Result<()> { + let mut last_error = Error::from_win32(); + + for attempt in 0..max_retries { + match self.try_connect() { + Ok(()) => return Ok(()), + Err(e) => { + last_error = e; + if attempt < max_retries - 1 { + std::thread::sleep(retry_delay); + } + } + } + } + + Err(last_error) + } + + /// Attempt to connect to the named pipe + fn try_connect(&mut self) -> Result<()> { + unsafe { + // Try to open the named pipe + let pipe_name = HSTRING::from(BROKER_PIPE_NAME); + + let handle = CreateFileW( + &pipe_name, + FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0, + FILE_SHARE_NONE, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + None, + )?; + + // Set the pipe to message mode + let mut mode = PIPE_READMODE_MESSAGE; + SetNamedPipeHandleState( + handle, + Some(&mut mode), + None, + None, + )?; + + self.pipe_handle = Some(handle); + Ok(()) + } + } + + /// Check if connected to the broker + pub fn is_connected(&self) -> bool { + self.pipe_handle.is_some() + } + + /// Disconnect from the broker + pub fn disconnect(&mut self) { + if let Some(handle) = self.pipe_handle.take() { + unsafe { + let _ = CloseHandle(handle); + } + } + } + + /// Send an elevation request and receive the response + pub fn elevate( + &mut self, + request: &ElevationRequest, + ) -> Result { + if !self.is_connected() { + return Err(Error::from(E_NOT_SET)); + } + + // Validate request + request.validate().map_err(|e| Error::from_win32())?; + + // Serialize request + let request_bytes = request.to_bytes() + .map_err(|_| Error::from_win32())?; + + // Send request + self.send_message(&request_bytes)?; + + // Receive response + let response_bytes = self.receive_message()?; + + // Deserialize response + let response = ElevationResponse::from_bytes(&response_bytes) + .map_err(|_| Error::from_win32())?; + + Ok(response) + } + + /// Send a message through the pipe + fn send_message(&self, data: &[u8]) -> Result<()> { + let handle = self.pipe_handle.ok_or(Error::from(E_NOT_SET))?; + + unsafe { + // Send message length first (4 bytes, little-endian) + let len_bytes = (data.len() as u32).to_le_bytes(); + let mut bytes_written = 0; + WriteFile( + handle, + Some(&len_bytes), + Some(&mut bytes_written), + None, + )?; + + // Send message data + bytes_written = 0; + WriteFile( + handle, + Some(data), + Some(&mut bytes_written), + None, + )?; + + if bytes_written != data.len() as u32 { + return Err(Error::from_win32()); + } + + Ok(()) + } + } + + /// Receive a message from the pipe + fn receive_message(&self) -> Result> { + let handle = self.pipe_handle.ok_or(Error::from(E_NOT_SET))?; + + unsafe { + // Read message length (4 bytes) + let mut len_bytes = [0u8; 4]; + let mut bytes_read = 0; + ReadFile( + handle, + Some(&mut len_bytes), + Some(&mut bytes_read), + None, + )?; + + if bytes_read != 4 { + return Err(Error::from_win32()); + } + + let message_len = u32::from_le_bytes(len_bytes) as usize; + + // Validate message length + if message_len > MAX_MESSAGE_SIZE { + return Err(Error::from_win32()); + } + + // Read message data + let mut buffer = vec![0u8; message_len]; + bytes_read = 0; + ReadFile( + handle, + Some(&mut buffer), + Some(&mut bytes_read), + None, + )?; + + if bytes_read as usize != message_len { + return Err(Error::from_win32()); + } + + Ok(buffer) + } + } + + /// Receive output chunks from the elevated process + pub fn receive_output(&mut self, mut callback: F) -> Result<()> + where + F: FnMut(OutputChunk) -> bool, + { + loop { + let chunk_bytes = self.receive_message()?; + let chunk = OutputChunk::from_bytes(&chunk_bytes) + .map_err(|_| Error::from_win32())?; + + let is_final = chunk.is_final; + + // Call the callback with the chunk + let should_continue = callback(chunk); + + if !should_continue || is_final { + break; + } + } + + Ok(()) + } +} + +impl Drop for BrokerClient { + fn drop(&mut self) { + self.disconnect(); + } +} + +/// High-level function to elevate a process via the broker +pub fn elevate_via_broker( + command: &str, + arguments: &[String], + execution_mode: ExecutionMode, + auth_token: Vec, +) -> Result { + let mut client = BrokerClient::new(); + + // Connect to broker + client.connect().map_err(|e| { + eprintln!("āŒ Failed to connect to elevation broker service"); + eprintln!(" Make sure the SudoElevationBroker service is running."); + eprintln!(" You can start it with: sc start SudoElevationBroker"); + e + })?; + + // Build request + let mut request = ElevationRequest::new( + command.to_string(), + arguments.to_vec(), + ); + request.execution_mode = execution_mode; + request.auth_token = auth_token; + + // Get current working directory + if let Ok(cwd) = std::env::current_dir() { + request.working_directory = Some(cwd.to_string_lossy().to_string()); + } + + // Send elevation request + let response = client.elevate(&request)?; + + // If inline mode, receive output + if execution_mode == ExecutionMode::Inline { + if response.status.is_success() { + client.receive_output(|chunk| { + use std::io::{stdout, stderr, Write}; + + match chunk.stream { + OutputStreamType::Stdout => { + let _ = stdout().write_all(&chunk.data); + let _ = stdout().flush(); + } + OutputStreamType::Stderr => { + let _ = stderr().write_all(&chunk.data); + let _ = stderr().flush(); + } + } + + true // Continue receiving + })?; + } + } + + Ok(response) +} + +/// Check if the broker service is available and responsive +pub fn check_broker_availability() -> bool { + let mut client = BrokerClient::new(); + client.connect().is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] // Requires broker service to be running + fn test_broker_connection() { + let mut client = BrokerClient::new(); + let result = client.connect(); + + if result.is_ok() { + assert!(client.is_connected()); + client.disconnect(); + assert!(!client.is_connected()); + } else { + println!("Broker service not available for testing"); + } + } + + #[test] + fn test_broker_availability_check() { + // This test may pass or fail depending on whether service is running + let available = check_broker_availability(); + println!("Broker available: {}", available); + } + + #[test] + fn test_broker_client_creation() { + let client = BrokerClient::new(); + assert!(!client.is_connected()); + assert_eq!(client.timeout, Duration::from_millis(DEFAULT_TIMEOUT_MS as u64)); + } + + #[test] + fn test_broker_client_with_timeout() { + let custom_timeout = Duration::from_secs(60); + let client = BrokerClient::new().with_timeout(custom_timeout); + assert_eq!(client.timeout, custom_timeout); + } + + #[test] + fn test_disconnect_when_not_connected() { + let mut client = BrokerClient::new(); + // Should not panic + client.disconnect(); + assert!(!client.is_connected()); + } + + #[test] + fn test_is_connected_initial_state() { + let client = BrokerClient::new(); + assert!(!client.is_connected(), "Client should not be connected initially"); + } + + #[test] + fn test_elevate_without_connection() { + let mut client = BrokerClient::new(); + + let request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![], + ); + + let result = client.elevate(&request); + assert!(result.is_err(), "Elevate should fail when not connected"); + } + + #[test] + fn test_request_validation_before_send() { + // This test validates that the client checks request validity + let mut client = BrokerClient::new(); + + // Create an invalid request (empty command) + let mut request = ElevationRequest::new( + "".to_string(), + vec![], + ); + + // Validate should fail + let validation_result = request.validate(); + assert!(validation_result.is_err()); + assert!(validation_result.unwrap_err().contains("Command cannot be empty")); + } + + #[test] + fn test_multiple_disconnects() { + let mut client = BrokerClient::new(); + + // Multiple disconnects should be safe + client.disconnect(); + client.disconnect(); + client.disconnect(); + + assert!(!client.is_connected()); + } + + #[test] + fn test_check_broker_availability_doesnt_crash() { + // This function should not panic regardless of service state + let _ = check_broker_availability(); + } + + #[test] + fn test_default_timeout_matches_protocol() { + let client = BrokerClient::new(); + assert_eq!( + client.timeout.as_millis(), + DEFAULT_TIMEOUT_MS as u128, + "Client timeout should match protocol default" + ); + } + + #[test] + fn test_timeout_boundary_values() { + // Test various timeout values + let timeouts = vec![ + Duration::from_millis(0), + Duration::from_millis(1), + Duration::from_millis(1000), + Duration::from_secs(60), + Duration::from_secs(3600), + ]; + + for timeout in timeouts { + let client = BrokerClient::new().with_timeout(timeout); + assert_eq!(client.timeout, timeout); + } + } + + #[test] + fn test_builder_pattern_chaining() { + let timeout = Duration::from_secs(45); + let client = BrokerClient::new() + .with_timeout(timeout); + + assert_eq!(client.timeout, timeout); + assert!(!client.is_connected()); + } + + #[test] + fn test_client_state_after_disconnect() { + let mut client = BrokerClient::new(); + + // Set a custom timeout + client = client.with_timeout(Duration::from_secs(100)); + + // Disconnect (even though never connected) + client.disconnect(); + + // State should still be valid + assert!(!client.is_connected()); + assert_eq!(client.timeout, Duration::from_secs(100)); + } +} diff --git a/sudo/src/broker_protocol.rs b/sudo/src/broker_protocol.rs new file mode 100644 index 0000000..1090ae9 --- /dev/null +++ b/sudo/src/broker_protocol.rs @@ -0,0 +1,655 @@ +// Elevation Broker Protocol Definition +// +// This module defines the communication protocol between sudo.exe (client) +// and the SudoElevationBroker service (server) via named pipes. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Protocol version for compatibility checking +pub const PROTOCOL_VERSION: u32 = 1; + +/// Named pipe path for broker communication +pub const BROKER_PIPE_NAME: &str = r"\\.\pipe\SudoElevationBroker"; + +/// Maximum message size (16MB) +pub const MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024; + +/// Timeout for broker operations (30 seconds) +pub const DEFAULT_TIMEOUT_MS: u32 = 30_000; + +// ============================================================================ +// Request/Response Messages +// ============================================================================ + +/// Request to elevate and execute a process +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElevationRequest { + /// Protocol version + pub version: u32, + + /// Authentication token (from Windows Hello or other auth mechanism) + #[serde(with = "serde_bytes")] + pub auth_token: Vec, + + /// Executable path to run + pub command: String, + + /// Command-line arguments + pub arguments: Vec, + + /// Working directory (None = inherit from client) + pub working_directory: Option, + + /// Environment variables (only overrides/additions) + pub environment: HashMap, + + /// Execution mode + pub execution_mode: ExecutionMode, + + /// Timeout in milliseconds (0 = no timeout) + pub timeout_ms: u32, + + /// Whether to capture stdout + pub capture_stdout: bool, + + /// Whether to capture stderr + pub capture_stderr: bool, +} + +/// Response to an elevation request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ElevationResponse { + /// Request status + pub status: StatusCode, + + /// Error message if status is not Success + pub error_message: Option, + + /// Process ID of the elevated process (if successful) + pub process_id: Option, + + /// Exit code of the process (if completed) + pub exit_code: Option, + + /// Whether the process is still running + pub is_running: bool, +} + +/// Streaming output chunk from the elevated process +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputChunk { + /// Which output stream this data is from + pub stream: OutputStreamType, + + /// Output data + #[serde(with = "serde_bytes")] + pub data: Vec, + + /// Whether this is the final chunk for this stream + pub is_final: bool, + + /// Sequence number for ordering + pub sequence: u64, +} + +// ============================================================================ +// Enums +// ============================================================================ + +/// Execution modes for the elevated process +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExecutionMode { + /// Run inline - capture output and return when complete + Inline, + + /// Run in new window - create new console window + NewWindow, + + /// Run in current window - attach to current console + CurrentWindow, + + /// Disable input - prevent interactive input + DisableInput, + + /// Hidden - run without visible window + Hidden, +} + +impl ExecutionMode { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "inline" | "i" => Some(Self::Inline), + "new" | "newwindow" | "n" => Some(Self::NewWindow), + "current" | "currentwindow" | "c" => Some(Self::CurrentWindow), + "disableinput" | "d" => Some(Self::DisableInput), + "hidden" | "h" => Some(Self::Hidden), + _ => None, + } + } +} + +/// Status codes for elevation requests +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u32)] +pub enum StatusCode { + /// Operation completed successfully + Success = 0, + + /// Authentication failed + AuthenticationFailed = 1, + + /// User denied the elevation request + UserDenied = 2, + + /// The requested command was not found + CommandNotFound = 3, + + /// Access denied (insufficient privileges) + AccessDenied = 4, + + /// Timeout waiting for process completion + Timeout = 5, + + /// Invalid request parameters + InvalidRequest = 6, + + /// Broker service internal error + InternalError = 7, + + /// Unsupported protocol version + UnsupportedVersion = 8, + + /// Windows Hello not available + HelloNotAvailable = 9, + + /// Administrator Protection not enabled + APNotEnabled = 10, + + /// General error + Error = 999, +} + +impl StatusCode { + pub fn to_error_message(&self) -> &'static str { + match self { + Self::Success => "Success", + Self::AuthenticationFailed => "Authentication failed", + Self::UserDenied => "User denied the elevation request", + Self::CommandNotFound => "Command not found", + Self::AccessDenied => "Access denied", + Self::Timeout => "Operation timed out", + Self::InvalidRequest => "Invalid request parameters", + Self::InternalError => "Internal broker service error", + Self::UnsupportedVersion => "Unsupported protocol version", + Self::HelloNotAvailable => "Windows Hello is not available", + Self::APNotEnabled => "Administrator Protection is not enabled", + Self::Error => "An error occurred", + } + } + + pub fn is_success(&self) -> bool { + matches!(self, Self::Success) + } +} + +/// Output stream type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OutputStreamType { + /// Standard output + Stdout, + + /// Standard error + Stderr, +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +impl ElevationRequest { + /// Create a new elevation request with defaults + pub fn new(command: String, arguments: Vec) -> Self { + Self { + version: PROTOCOL_VERSION, + auth_token: Vec::new(), + command, + arguments, + working_directory: None, + environment: HashMap::new(), + execution_mode: ExecutionMode::Inline, + timeout_ms: DEFAULT_TIMEOUT_MS, + capture_stdout: true, + capture_stderr: true, + } + } + + /// Validate the request + pub fn validate(&self) -> Result<(), String> { + if self.version != PROTOCOL_VERSION { + return Err(format!( + "Unsupported protocol version: {} (expected {})", + self.version, PROTOCOL_VERSION + )); + } + + if self.command.is_empty() { + return Err("Command cannot be empty".to_string()); + } + + if self.timeout_ms > 0 && self.timeout_ms < 1000 { + return Err("Timeout must be at least 1000ms or 0 for no timeout".to_string()); + } + + Ok(()) + } + + /// Serialize to bytes for transmission + pub fn to_bytes(&self) -> Result, String> { + bincode::serialize(self) + .map_err(|e| format!("Failed to serialize request: {}", e)) + } + + /// Deserialize from bytes + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() > MAX_MESSAGE_SIZE { + return Err(format!( + "Message too large: {} bytes (max {})", + data.len(), + MAX_MESSAGE_SIZE + )); + } + + bincode::deserialize(data) + .map_err(|e| format!("Failed to deserialize request: {}", e)) + } +} + +impl ElevationResponse { + /// Create a success response + pub fn success(process_id: u32) -> Self { + Self { + status: StatusCode::Success, + error_message: None, + process_id: Some(process_id), + exit_code: None, + is_running: true, + } + } + + /// Create an error response + pub fn error(status: StatusCode, message: impl Into) -> Self { + Self { + status, + error_message: Some(message.into()), + process_id: None, + exit_code: None, + is_running: false, + } + } + + /// Create a completed response + pub fn completed(process_id: u32, exit_code: i32) -> Self { + Self { + status: StatusCode::Success, + error_message: None, + process_id: Some(process_id), + exit_code: Some(exit_code), + is_running: false, + } + } + + /// Serialize to bytes for transmission + pub fn to_bytes(&self) -> Result, String> { + bincode::serialize(self) + .map_err(|e| format!("Failed to serialize response: {}", e)) + } + + /// Deserialize from bytes + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() > MAX_MESSAGE_SIZE { + return Err(format!( + "Message too large: {} bytes (max {})", + data.len(), + MAX_MESSAGE_SIZE + )); + } + + bincode::deserialize(data) + .map_err(|e| format!("Failed to deserialize response: {}", e)) + } +} + +impl OutputChunk { + /// Create a new output chunk + pub fn new(stream: OutputStreamType, data: Vec, sequence: u64, is_final: bool) -> Self { + Self { + stream, + data, + is_final, + sequence, + } + } + + /// Serialize to bytes for transmission + pub fn to_bytes(&self) -> Result, String> { + bincode::serialize(self) + .map_err(|e| format!("Failed to serialize output chunk: {}", e)) + } + + /// Deserialize from bytes + pub fn from_bytes(data: &[u8]) -> Result { + if data.len() > MAX_MESSAGE_SIZE { + return Err(format!( + "Message too large: {} bytes (max {})", + data.len(), + MAX_MESSAGE_SIZE + )); + } + + bincode::deserialize(data) + .map_err(|e| format!("Failed to deserialize output chunk: {}", e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_elevation_request_serialization() { + let mut request = ElevationRequest::new( + "notepad.exe".to_string(), + vec!["test.txt".to_string()], + ); + request.working_directory = Some("C:\\Users\\Test".to_string()); + request.environment.insert("TEST_VAR".to_string(), "value".to_string()); + + let bytes = request.to_bytes().unwrap(); + let decoded = ElevationRequest::from_bytes(&bytes).unwrap(); + + assert_eq!(request.command, decoded.command); + assert_eq!(request.arguments, decoded.arguments); + assert_eq!(request.working_directory, decoded.working_directory); + } + + #[test] + fn test_execution_mode_parsing() { + assert_eq!(ExecutionMode::from_str("inline"), Some(ExecutionMode::Inline)); + assert_eq!(ExecutionMode::from_str("NEW"), Some(ExecutionMode::NewWindow)); + assert_eq!(ExecutionMode::from_str("hidden"), Some(ExecutionMode::Hidden)); + assert_eq!(ExecutionMode::from_str("invalid"), None); + } + + #[test] + fn test_status_code_messages() { + assert_eq!(StatusCode::Success.to_error_message(), "Success"); + assert!(StatusCode::Success.is_success()); + assert!(!StatusCode::AccessDenied.is_success()); + } + + #[test] + fn test_protocol_version_validation() { + let mut request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![], + ); + + // Valid version + request.version = PROTOCOL_VERSION; + assert!(request.validate().is_ok()); + + // Invalid version + request.version = 999; + let result = request.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported protocol version")); + } + + #[test] + fn test_empty_command_validation() { + let mut request = ElevationRequest::new( + "".to_string(), + vec![], + ); + + let result = request.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Command cannot be empty")); + } + + #[test] + fn test_timeout_validation() { + let mut request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![], + ); + + // Valid timeouts + request.timeout_ms = 0; // No timeout + assert!(request.validate().is_ok()); + + request.timeout_ms = 1000; // Minimum valid + assert!(request.validate().is_ok()); + + request.timeout_ms = 60_000; // Normal timeout + assert!(request.validate().is_ok()); + + // Invalid timeout (too small) + request.timeout_ms = 500; + let result = request.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("at least 1000ms")); + } + + #[test] + fn test_message_size_limit() { + // Create a large message that exceeds MAX_MESSAGE_SIZE + let oversized_data = vec![0u8; MAX_MESSAGE_SIZE + 1]; + + let result = ElevationRequest::from_bytes(&oversized_data); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Message too large")); + } + + #[test] + fn test_invalid_serialization_data() { + // Invalid bincode data + let invalid_data = vec![0xFF, 0xFF, 0xFF, 0xFF]; + + let result = ElevationRequest::from_bytes(&invalid_data); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to deserialize")); + } + + #[test] + fn test_elevation_response_success() { + let response = ElevationResponse::success(1234); + + assert_eq!(response.status, StatusCode::Success); + assert!(response.error_message.is_none()); + assert_eq!(response.process_id, Some(1234)); + assert!(response.is_running); + } + + #[test] + fn test_elevation_response_error() { + let response = ElevationResponse::error( + StatusCode::AccessDenied, + "Permission denied" + ); + + assert_eq!(response.status, StatusCode::AccessDenied); + assert_eq!(response.error_message, Some("Permission denied".to_string())); + assert!(response.process_id.is_none()); + assert!(!response.is_running); + } + + #[test] + fn test_output_chunk_serialization() { + let chunk = OutputChunk { + stream: OutputStreamType::Stdout, + data: b"test output".to_vec(), + is_final: false, + sequence: 42, + }; + + let bytes = bincode::serialize(&chunk).unwrap(); + let decoded: OutputChunk = bincode::deserialize(&bytes).unwrap(); + + assert_eq!(chunk.data, decoded.data); + assert_eq!(chunk.sequence, decoded.sequence); + assert_eq!(chunk.is_final, decoded.is_final); + } + + #[test] + fn test_large_environment_variables() { + let mut request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![], + ); + + // Add many environment variables + for i in 0..100 { + request.environment.insert( + format!("VAR_{}", i), + format!("VALUE_{}", i) + ); + } + + let bytes = request.to_bytes().unwrap(); + assert!(bytes.len() < MAX_MESSAGE_SIZE); + + let decoded = ElevationRequest::from_bytes(&bytes).unwrap(); + assert_eq!(request.environment.len(), decoded.environment.len()); + assert_eq!(request.environment.get("VAR_0"), decoded.environment.get("VAR_0")); + } + + #[test] + fn test_large_arguments_list() { + let mut arguments = Vec::new(); + for i in 0..1000 { + arguments.push(format!("arg_{}", i)); + } + + let request = ElevationRequest::new( + "cmd.exe".to_string(), + arguments.clone(), + ); + + let bytes = request.to_bytes().unwrap(); + let decoded = ElevationRequest::from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.arguments.len(), 1000); + assert_eq!(decoded.arguments[0], "arg_0"); + assert_eq!(decoded.arguments[999], "arg_999"); + } + + #[test] + fn test_auth_token_handling() { + let mut request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![], + ); + + // Add auth token (simulate HMAC-signed token) + let token = vec![ + 1, 0, 0, 0, // version + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // timestamp + 0xAA, 0xBB, 0xCC, 0xDD, // lengths + ]; + request.auth_token = token.clone(); + + let bytes = request.to_bytes().unwrap(); + let decoded = ElevationRequest::from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.auth_token, token); + } + + #[test] + fn test_all_execution_modes() { + let modes = vec![ + ExecutionMode::Inline, + ExecutionMode::NewWindow, + ExecutionMode::CurrentWindow, + ExecutionMode::DisableInput, + ExecutionMode::Hidden, + ]; + + for mode in modes { + let mut request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![], + ); + request.execution_mode = mode; + + let bytes = request.to_bytes().unwrap(); + let decoded = ElevationRequest::from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.execution_mode, mode); + } + } + + #[test] + fn test_output_stream_types() { + let stdout_chunk = OutputChunk { + stream: OutputStreamType::Stdout, + data: vec![], + is_final: false, + sequence: 0, + }; + + let stderr_chunk = OutputChunk { + stream: OutputStreamType::Stderr, + data: vec![], + is_final: false, + sequence: 0, + }; + + let stdout_bytes = bincode::serialize(&stdout_chunk).unwrap(); + let stderr_bytes = bincode::serialize(&stderr_chunk).unwrap(); + + let decoded_stdout: OutputChunk = bincode::deserialize(&stdout_bytes).unwrap(); + let decoded_stderr: OutputChunk = bincode::deserialize(&stderr_bytes).unwrap(); + + assert!(matches!(decoded_stdout.stream, OutputStreamType::Stdout)); + assert!(matches!(decoded_stderr.stream, OutputStreamType::Stderr)); + } + + #[test] + fn test_unicode_in_command_and_args() { + let request = ElevationRequest::new( + "cmd.exe".to_string(), + vec![ + "test_ę—„ęœ¬čŖž.txt".to_string(), + "тест_кириллица.txt".to_string(), + "emoji_šŸš€.txt".to_string(), + ], + ); + + let bytes = request.to_bytes().unwrap(); + let decoded = ElevationRequest::from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.arguments[0], "test_ę—„ęœ¬čŖž.txt"); + assert_eq!(decoded.arguments[1], "тест_кириллица.txt"); + assert_eq!(decoded.arguments[2], "emoji_šŸš€.txt"); + } + + #[test] + fn test_working_directory_none_vs_some() { + let mut request1 = ElevationRequest::new("cmd.exe".to_string(), vec![]); + request1.working_directory = None; + + let mut request2 = ElevationRequest::new("cmd.exe".to_string(), vec![]); + request2.working_directory = Some("C:\\Test".to_string()); + + let bytes1 = request1.to_bytes().unwrap(); + let bytes2 = request2.to_bytes().unwrap(); + + let decoded1 = ElevationRequest::from_bytes(&bytes1).unwrap(); + let decoded2 = ElevationRequest::from_bytes(&bytes2).unwrap(); + + assert!(decoded1.working_directory.is_none()); + assert_eq!(decoded2.working_directory, Some("C:\\Test".to_string())); + } +} diff --git a/sudo/src/hello_auth.rs b/sudo/src/hello_auth.rs new file mode 100644 index 0000000..536bd28 --- /dev/null +++ b/sudo/src/hello_auth.rs @@ -0,0 +1,513 @@ +// Windows Hello Authentication Module +// +// This module provides Windows Hello (biometric/PIN) authentication +// for privilege elevation requests. + +use anyhow::{anyhow, Result}; +use std::ffi::c_void; +use windows::{ + core::*, + Foundation::IAsyncOperation, + Security::Credentials::UI::*, + Win32::Foundation::*, + Win32::System::Com::*, +}; + +/// Windows Hello authenticator +pub struct HelloAuthenticator { + message: String, +} + +impl HelloAuthenticator { + /// Create a new Hello authenticator with a custom message + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Create authenticator with default message + pub fn default_message() -> Self { + Self::new("Authenticate to run elevated command") + } + + /// Check if Windows Hello is available on this device + pub fn check_availability() -> Result { + // Initialize COM for WinRT + unsafe { + CoInitializeEx(None, COINIT_MULTITHREADED).ok()?; + } + + let availability_op = UserConsentVerifier::CheckAvailabilityAsync()?; + let availability = block_on_async(availability_op)?; + + Ok(availability) + } + + /// Request Windows Hello authentication + pub fn authenticate(&self) -> Result> { + // Initialize COM for WinRT + unsafe { + CoInitializeEx(None, COINIT_MULTITHREADED).ok()?; + } + + // Request verification + let message_hstring = HSTRING::from(&self.message); + let verification_op = UserConsentVerifier::RequestVerificationAsync(&message_hstring)?; + let result = block_on_async(verification_op)?; + + match result { + UserConsentVerificationResult::Verified => { + // Generate authentication token + // In a real implementation, this would be a cryptographic token + // For now, we'll use a simple marker + Ok(generate_auth_token()) + } + UserConsentVerificationResult::DeviceNotPresent => { + Err(anyhow!("No biometric device found on this system")) + } + UserConsentVerificationResult::NotConfiguredForUser => { + Err(anyhow!( + "Windows Hello is not configured for this user.\n\ + Please set up Windows Hello in Settings → Accounts → Sign-in options" + )) + } + UserConsentVerificationResult::DisabledByPolicy => { + Err(anyhow!("Windows Hello is disabled by policy")) + } + UserConsentVerificationResult::DeviceBusy => { + Err(anyhow!("Biometric device is busy. Please try again")) + } + UserConsentVerificationResult::RetriesExhausted => { + Err(anyhow!("Too many failed authentication attempts")) + } + UserConsentVerificationResult::Canceled => { + Err(anyhow!("Authentication was canceled by the user")) + } + _ => { + Err(anyhow!("Windows Hello authentication failed")) + } + } + } +} + +impl Default for HelloAuthenticator { + fn default() -> Self { + Self::default_message() + } +} + +/// High-level function to authenticate with Windows Hello +pub fn authenticate_with_hello(message: Option<&str>) -> Result> { + let authenticator = match message { + Some(msg) => HelloAuthenticator::new(msg), + None => HelloAuthenticator::default_message(), + }; + + authenticator.authenticate() +} + +/// Generate a cryptographically signed authentication token +/// +/// This token is used for audit trail integrity, not for authentication. +/// The actual authentication is performed by the broker service via +/// ImpersonateNamedPipeClient and checking the client's group membership. +/// +/// Token format: +/// - Version (4 bytes) +/// - Timestamp (8 bytes) +/// - User SID length (4 bytes) +/// - User SID (variable) +/// - Machine name length (4 bytes) +/// - Machine name (variable) +/// - Nonce (16 bytes) +/// - HMAC-SHA256 signature (32 bytes) +fn generate_auth_token() -> Vec { + use std::time::{SystemTime, UNIX_EPOCH}; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + use windows::Win32::Foundation::HANDLE; + + type HmacSha256 = Hmac; + + const TOKEN_VERSION: u32 = 1; + + // Get current timestamp + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Get user SID + let user_sid = get_current_user_sid().unwrap_or_else(|_| b"UNKNOWN_SID".to_vec()); + + // Get machine name + let machine_name = get_machine_name().unwrap_or_else(|_| "UNKNOWN_MACHINE".to_string()); + let machine_bytes = machine_name.as_bytes(); + + // Generate nonce (random bytes) + let nonce: [u8; 16] = [ + (timestamp & 0xFF) as u8, + ((timestamp >> 8) & 0xFF) as u8, + ((timestamp >> 16) & 0xFF) as u8, + ((timestamp >> 24) & 0xFF) as u8, + ((timestamp >> 32) & 0xFF) as u8, + ((timestamp >> 40) & 0xFF) as u8, + ((timestamp >> 48) & 0xFF) as u8, + ((timestamp >> 56) & 0xFF) as u8, + // Mix in some additional entropy + (user_sid.len() & 0xFF) as u8, + ((user_sid.len() >> 8) & 0xFF) as u8, + (machine_bytes.len() & 0xFF) as u8, + ((machine_bytes.len() >> 8) & 0xFF) as u8, + // Additional pseudo-random bytes from timestamp + ((timestamp ^ 0x5555AAAA) & 0xFF) as u8, + (((timestamp ^ 0x5555AAAA) >> 8) & 0xFF) as u8, + (((timestamp ^ 0xAAAA5555) >> 16) & 0xFF) as u8, + (((timestamp ^ 0xAAAA5555) >> 24) & 0xFF) as u8, + ]; + + // Build the token data to be signed + let mut token_data = Vec::new(); + token_data.extend_from_slice(&TOKEN_VERSION.to_le_bytes()); + token_data.extend_from_slice(×tamp.to_le_bytes()); + token_data.extend_from_slice(&(user_sid.len() as u32).to_le_bytes()); + token_data.extend_from_slice(&user_sid); + token_data.extend_from_slice(&(machine_bytes.len() as u32).to_le_bytes()); + token_data.extend_from_slice(machine_bytes); + token_data.extend_from_slice(&nonce); + + // Create HMAC signature + // Key is derived from a combination of machine-specific data + // In a production deployment, this could be a service-managed key + let hmac_key = derive_hmac_key(); + let mut mac = HmacSha256::new_from_slice(&hmac_key) + .expect("HMAC can take key of any size"); + mac.update(&token_data); + let signature = mac.finalize().into_bytes(); + + // Append signature to token + token_data.extend_from_slice(&signature); + + token_data +} + +/// Get the current user's SID as bytes +fn get_current_user_sid() -> Result> { + unsafe { + let mut token: HANDLE = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)?; + + // Get the size needed + let mut size = 0u32; + let _ = GetTokenInformation(token, TokenUser, None, 0, &mut size); + + // Allocate buffer and get the token information + let mut buffer = vec![0u8; size as usize]; + GetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr() as *mut _), + size, + &mut size, + )?; + + // Extract SID from TOKEN_USER structure + let token_user = &*(buffer.as_ptr() as *const TOKEN_USER); + let sid_ptr = token_user.User.Sid; + + // Convert SID to string for storage + use windows::Win32::Security::ConvertSidToStringSidW; + let mut sid_string = windows::core::PWSTR::null(); + ConvertSidToStringSidW(sid_ptr, &mut sid_string)?; + + let sid_str = sid_string.to_string()?; + Ok(sid_str.as_bytes().to_vec()) + } +} + +/// Get the machine name +fn get_machine_name() -> Result { + use windows::Win32::System::SystemInformation::{GetComputerNameExW, ComputerNameDnsHostname}; + + unsafe { + let mut size = 0u32; + let _ = GetComputerNameExW(ComputerNameDnsHostname, windows::core::PWSTR::null(), &mut size); + + let mut buffer = vec![0u16; size as usize]; + GetComputerNameExW( + ComputerNameDnsHostname, + windows::core::PWSTR(buffer.as_mut_ptr()), + &mut size, + )?; + + Ok(String::from_utf16_lossy(&buffer[..size as usize])) + } +} + +/// Derive HMAC key from machine-specific data +/// +/// In production, this could be: +/// - A key stored securely by the broker service (LocalSystem) +/// - Derived from TPM or other hardware security module +/// - Managed via Windows DPAPI +/// +/// For this implementation, we derive from machine GUID and process info. +fn derive_hmac_key() -> Vec { + use sha2::{Digest, Sha256}; + + // Get machine GUID from registry + let machine_guid = get_machine_guid().unwrap_or_else(|_| "DEFAULT_MACHINE_GUID".to_string()); + + // Derive key using SHA256 + let mut hasher = Sha256::new(); + hasher.update(b"SUDO_AP_BROKER_V1"); + hasher.update(machine_guid.as_bytes()); + hasher.update(b"HELLO_AUTH_TOKEN_KEY"); + + hasher.finalize().to_vec() +} + +/// Get machine GUID from registry +fn get_machine_guid() -> Result { + use windows_registry::Key; + + let key = Key::Local.open(r"SOFTWARE\Microsoft\Cryptography")?; + let guid: String = key.get_value("MachineGuid")?; + Ok(guid) +} + +/// Verify an authentication token's signature +/// +/// This function can be called by the broker service to verify +/// the integrity of the token for audit purposes. +#[allow(dead_code)] +pub fn verify_auth_token(token: &[u8]) -> Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + type HmacSha256 = Hmac; + + // Token must be at least: version(4) + timestamp(8) + sid_len(4) + machine_len(4) + nonce(16) + signature(32) = 68 bytes + if token.len() < 68 { + return Ok(false); + } + + // Split token into data and signature + let (data, signature) = token.split_at(token.len() - 32); + + // Recompute HMAC + let hmac_key = derive_hmac_key(); + let mut mac = HmacSha256::new_from_slice(&hmac_key) + .expect("HMAC can take key of any size"); + mac.update(data); + + // Verify signature + mac.verify_slice(signature) + .map(|_| true) + .or(Ok(false)) +} + +/// Block on an async Windows Runtime operation +/// This is a simple synchronous wrapper for WinRT async operations +fn block_on_async(operation: IAsyncOperation) -> Result +where + T: RuntimeType + 'static, +{ + use std::time::Duration; + use std::thread; + + // Wait for the operation to complete + loop { + match operation.Status()? { + AsyncStatus::Completed => { + return Ok(operation.GetResults()?); + } + AsyncStatus::Error => { + let error_code = operation.ErrorCode()?; + return Err(anyhow!("Async operation failed with error: {:?}", error_code)); + } + AsyncStatus::Canceled => { + return Err(anyhow!("Async operation was canceled")); + } + AsyncStatus::Started => { + // Still running, wait a bit + thread::sleep(Duration::from_millis(50)); + } + _ => { + return Err(anyhow!("Unknown async status")); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore] // Requires Windows Hello to be configured + fn test_check_availability() { + let result = HelloAuthenticator::check_availability(); + assert!(result.is_ok()); + + match result.unwrap() { + UserConsentVerifierAvailability::Available => { + println!("Windows Hello is available"); + } + UserConsentVerifierAvailability::DeviceNotPresent => { + println!("No biometric device present"); + } + UserConsentVerifierAvailability::NotConfiguredForUser => { + println!("Windows Hello not configured for user"); + } + UserConsentVerifierAvailability::DisabledByPolicy => { + println!("Windows Hello disabled by policy"); + } + UserConsentVerifierAvailability::DeviceBusy => { + println!("Biometric device busy"); + } + _ => { + println!("Unknown availability status"); + } + } + } + + #[test] + #[ignore] // Requires user interaction + fn test_authenticate() { + let authenticator = HelloAuthenticator::default_message(); + let result = authenticator.authenticate(); + + match result { + Ok(token) => { + println!("Authentication successful! Token length: {}", token.len()); + assert!(!token.is_empty()); + } + Err(e) => { + println!("Authentication failed: {:#}", e); + } + } + } + + #[test] + fn test_token_generation() { + // Test that tokens are generated and are non-empty + let token = generate_auth_token(); + + // Token should be at least: version(4) + timestamp(8) + sid_len(4) + machine_len(4) + nonce(16) + signature(32) = 68 bytes + assert!(token.len() >= 68, "Token too short: {} bytes", token.len()); + + // Verify token structure + let version = u32::from_le_bytes([token[0], token[1], token[2], token[3]]); + assert_eq!(version, 1, "Token version should be 1"); + } + + #[test] + fn test_token_verification() { + // Generate a token + let token = generate_auth_token(); + + // Verify it + let result = verify_auth_token(&token); + assert!(result.is_ok(), "Token verification failed"); + assert!(result.unwrap(), "Token should be valid"); + } + + #[test] + fn test_token_verification_invalid() { + // Create an invalid token (too short) + let invalid_token = vec![1, 2, 3, 4]; + + let result = verify_auth_token(&invalid_token); + assert!(result.is_ok()); + assert!(!result.unwrap(), "Invalid token should not verify"); + } + + #[test] + fn test_token_verification_tampered() { + // Generate a valid token + let mut token = generate_auth_token(); + + // Tamper with the signature (last 32 bytes) + let len = token.len(); + token[len - 1] ^= 0xFF; // Flip bits in last byte + + // Verification should fail + let result = verify_auth_token(&token); + assert!(result.is_ok()); + assert!(!result.unwrap(), "Tampered token should not verify"); + } + + #[test] + fn test_derive_hmac_key() { + // Test that key derivation is deterministic + let key1 = derive_hmac_key(); + let key2 = derive_hmac_key(); + + assert_eq!(key1, key2, "HMAC key derivation should be deterministic"); + assert_eq!(key1.len(), 32, "HMAC key should be 32 bytes (SHA256 output)"); + } + + #[test] + fn test_get_current_user_sid() { + // Test that we can get current user's SID + let result = get_current_user_sid(); + assert!(result.is_ok(), "Should be able to get current user SID"); + + let sid = result.unwrap(); + assert!(!sid.is_empty(), "SID should not be empty"); + + // SID should start with "S-1-" + let sid_str = String::from_utf8_lossy(&sid); + assert!(sid_str.starts_with("S-1-"), "SID should start with S-1-"); + } + + #[test] + fn test_get_machine_name() { + // Test that we can get machine name + let result = get_machine_name(); + assert!(result.is_ok(), "Should be able to get machine name"); + + let name = result.unwrap(); + assert!(!name.is_empty(), "Machine name should not be empty"); + } + + #[test] + fn test_get_machine_guid() { + // Test that we can get machine GUID + let result = get_machine_guid(); + assert!(result.is_ok(), "Should be able to get machine GUID"); + + let guid = result.unwrap(); + assert!(!guid.is_empty(), "Machine GUID should not be empty"); + + // GUID should be in format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + assert!(guid.contains('-'), "GUID should contain hyphens"); + } + + #[test] + fn test_hello_authenticator_creation() { + let auth1 = HelloAuthenticator::new("Custom message"); + assert_eq!(auth1.message, "Custom message"); + + let auth2 = HelloAuthenticator::default_message(); + assert_eq!(auth2.message, "Authenticate to run elevated command"); + + let auth3 = HelloAuthenticator::default(); + assert_eq!(auth3.message, auth2.message); + } + + #[test] + fn test_token_uniqueness() { + // Generate multiple tokens and verify they're different + // (due to timestamp and nonce differences) + let token1 = generate_auth_token(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let token2 = generate_auth_token(); + + assert_ne!(token1, token2, "Tokens should be unique"); + } +} diff --git a/sudo/src/lib.rs b/sudo/src/lib.rs new file mode 100644 index 0000000..9a13490 --- /dev/null +++ b/sudo/src/lib.rs @@ -0,0 +1,15 @@ +// Sudo for Windows - Library +// This module exports the public API for sudo functionality + +pub mod ap_detection; +pub mod broker_client; +pub mod broker_protocol; +pub mod hello_auth; + +pub use ap_detection::{ElevationCapabilities, ElevationEnvironment, HelloAvailability}; +pub use broker_client::{BrokerClient, check_broker_availability, elevate_via_broker}; +pub use broker_protocol::{ + ElevationRequest, ElevationResponse, ExecutionMode, OutputChunk, StatusCode, + BROKER_PIPE_NAME, DEFAULT_TIMEOUT_MS, MAX_MESSAGE_SIZE, PROTOCOL_VERSION, +}; +pub use hello_auth::{HelloAuthenticator, authenticate_with_hello}; diff --git a/sudo/src/main.rs b/sudo/src/main.rs index 5765012..103424c 100644 --- a/sudo/src/main.rs +++ b/sudo/src/main.rs @@ -1,4 +1,8 @@ +mod ap_detection; +mod broker_client; +mod broker_protocol; mod elevate_handler; +mod hello_auth; mod helpers; mod logging_bindings; mod messages; diff --git a/sudo/src/run_handler.rs b/sudo/src/run_handler.rs index 2c35193..0d7b0dc 100644 --- a/sudo/src/run_handler.rs +++ b/sudo/src/run_handler.rs @@ -1,4 +1,8 @@ +use crate::ap_detection::{ElevationCapabilities, ElevationEnvironment, HelloAvailability}; +use crate::broker_client; +use crate::broker_protocol::{ExecutionMode, StatusCode}; use crate::elevate_handler::spawn_target_for_request; +use crate::hello_auth; use crate::helpers::*; use crate::logging_bindings::event_log_request; use crate::messages::ElevateRequest; @@ -15,7 +19,8 @@ use windows::Win32::System::WindowsProgramming::PUBLIC_OBJECT_BASIC_INFORMATION; use windows::{ core::*, Wdk::System::Threading::*, Win32::Foundation::*, Win32::Storage::FileSystem::*, Win32::System::Console::*, Win32::System::Diagnostics::Debug::*, Win32::System::Rpc::*, - Win32::System::SystemInformation::*, Win32::System::Threading::*, Win32::UI::Shell::*, + Win32::System::SystemInformation::*, Win32::System::Threading::*, + Win32::System::Services::*, Win32::UI::Shell::*, Win32::UI::WindowsAndMessaging::*, }; @@ -310,35 +315,217 @@ fn do_request(req: ElevateRequest, copy_env: bool, manually_requested_dir: bool) // We're not running elevated here. We need to start the // elevated sudo and send it our request to handle. - // In ForceNewWindow mode, we want to use ShellExecuteEx to create the - // target process, whenever possible. This has the benefit of having the - // UAC display the target app directly, and also avoiding any RPC calls - // at all. - // - // However, there are caveats which prevent us from using ShellExecuteEx - // in all cases: - // * We can't use ShellExecuteEx if we need to copy the environment, - // because ShellExecuteEx doesn't allow us to set the environment of - // the target process. So if they want environment variables copied, - // we need to use RPC. - // * ShellExecuteEx will always set the CWD to system32, if the target - // exe is in the Windows dir. It does this _deep_ in the OS and - // there's nothing we can do to avoid it. So, if the user has - // requested a CWD, we need to use RPC. - // - Theoretically, we only need to use RPC if the target app is in - // the Windows dir, but we'd need to recreate the internal logic of - // CreateProcess to resolve the commandline we've been given here - // to determine that. - let should_use_runas = - req.sudo_mode == SudoMode::ForceNewWindow && !copy_env && !manually_requested_dir; - - if should_use_runas { - tracing::trace_log_message("Direct ShellExecute"); - runas_admin(&req.application, &join_args(&req.args), SW_NORMAL)?; - Ok(0) - } else { - tracing::trace_log_message("starting RPC handoff"); - handoff_to_elevated(&req) + // ==================================================================== + // ADMINISTRATOR PROTECTION (AP) DETECTION AND SMART ROUTING + // ==================================================================== + // Detect the elevation environment to determine the best elevation path: + // - StandardUAC: Traditional UAC elevation (legacy path) + // - AdminProtectionWithHello: AP enabled with Windows Hello → use broker + // - AdminProtectionWithoutHello: AP enabled but no Hello → error + // - NoAdminPrivileges: User lacks admin privileges → error + // - Unknown: Uncertain environment → fallback to legacy + + let capabilities = match ElevationCapabilities::detect() { + Ok(caps) => caps, + Err(e) => { + tracing::trace_log_message(&format!("AP detection failed: {:?}, using legacy path", e)); + // If detection fails, fall back to legacy behavior + return use_legacy_elevation(&req, copy_env, manually_requested_dir); + } + }; + + tracing::trace_log_message(&format!("Elevation environment: {:?}", capabilities.environment)); + tracing::trace_log_message(&format!("Hello availability: {:?}", capabilities.hello_availability)); + tracing::trace_log_message(&format!("Broker available: {}", capabilities.broker_available)); + + match capabilities.environment { + ElevationEnvironment::StandardUAC => { + // Traditional UAC elevation - use the existing legacy path + tracing::trace_log_message("Using legacy UAC elevation"); + use_legacy_elevation(&req, copy_env, manually_requested_dir) + } + + ElevationEnvironment::AdminProtectionWithHello => { + // AP is enabled and Windows Hello is configured + // Use the broker-based elevation path with Hello authentication + tracing::trace_log_message("Using Administrator Protection with Windows Hello"); + + // Check if broker is available + if !capabilities.broker_available { + eprintln!("Error: Windows Administrator Protection requires the sudo AP broker service."); + eprintln!("Please install and start the service:"); + eprintln!(" sc.exe create SudoAPBroker binPath=\"\\sudo_ap_broker.exe\""); + eprintln!(" sc.exe start SudoAPBroker"); + eprintln!(); + eprintln!("Alternatively, use the legacy elevation with --force-legacy flag."); + return Err(windows::core::Error::from_win32(ERROR_SERVICE_NOT_ACTIVE.0).into()); + } + + use_ap_broker_elevation(&req, copy_env) + } + + ElevationEnvironment::AdminProtectionWithoutHello => { + // AP is enabled but Windows Hello is not configured + // This is an error state - user needs to configure Hello + eprintln!("Error: Windows Administrator Protection is enabled but Windows Hello is not configured."); + eprintln!("Please configure Windows Hello in Settings:"); + eprintln!(" Settings > Accounts > Sign-in options > Windows Hello"); + eprintln!(); + eprintln!("Alternatively, disable Administrator Protection or use the legacy elevation"); + eprintln!("with --force-legacy flag (if policy allows)."); + return Err(windows::core::Error::from_win32(ERROR_AUTHENTICATION_FIREWALL_FAILED.0).into()); + } + + ElevationEnvironment::NoAdminPrivileges => { + // User is not an administrator - cannot elevate + eprintln!("Error: Current user does not have administrator privileges."); + eprintln!("Please run sudo as a user with administrator privileges."); + return Err(windows::core::Error::from_win32(ERROR_ACCESS_DENIED.0).into()); + } + + ElevationEnvironment::Unknown => { + // Could not determine the environment reliably + // Fall back to legacy elevation with a warning + tracing::trace_log_message("Warning: Unknown elevation environment, using legacy path"); + eprintln!("Warning: Could not determine elevation environment. Using legacy UAC elevation."); + use_legacy_elevation(&req, copy_env, manually_requested_dir) + } + } + } +} + +/// Helper function to use the legacy UAC elevation path +/// This encapsulates the original elevation logic for StandardUAC environments +fn use_legacy_elevation(req: &ElevateRequest, copy_env: bool, manually_requested_dir: bool) -> Result { + // In ForceNewWindow mode, we want to use ShellExecuteEx to create the + // target process, whenever possible. This has the benefit of having the + // UAC display the target app directly, and also avoiding any RPC calls + // at all. + // + // However, there are caveats which prevent us from using ShellExecuteEx + // in all cases: + // * We can't use ShellExecuteEx if we need to copy the environment, + // because ShellExecuteEx doesn't allow us to set the environment of + // the target process. So if they want environment variables copied, + // we need to use RPC. + // * ShellExecuteEx will always set the CWD to system32, if the target + // exe is in the Windows dir. It does this _deep_ in the OS and + // there's nothing we can do to avoid it. So, if the user has + // requested a CWD, we need to use RPC. + // - Theoretically, we only need to use RPC if the target app is in + // the Windows dir, but we'd need to recreate the internal logic of + // CreateProcess to resolve the commandline we've been given here + // to determine that. + let should_use_runas = + req.sudo_mode == SudoMode::ForceNewWindow && !copy_env && !manually_requested_dir; + + if should_use_runas { + tracing::trace_log_message("Direct ShellExecute"); + runas_admin(&req.application, &join_args(&req.args), SW_NORMAL)?; + Ok(0) + } else { + tracing::trace_log_message("starting RPC handoff"); + handoff_to_elevated(req) + } +} + +/// Helper function to use the Administrator Protection broker-based elevation +/// This authenticates with Windows Hello and communicates with the broker service +fn use_ap_broker_elevation(req: &ElevateRequest, copy_env: bool) -> Result { + // Step 1: Authenticate with Windows Hello + tracing::trace_log_message("Requesting Windows Hello authentication"); + + let auth_message = format!( + "sudo is requesting administrator privileges to run: {}", + req.application + ); + + let auth_token = match hello_auth::authenticate_with_hello(Some(&auth_message)) { + Ok(token) => { + tracing::trace_log_message("Windows Hello authentication successful"); + token + } + Err(e) => { + eprintln!("Windows Hello authentication failed: {}", e); + eprintln!(); + eprintln!("Elevation cancelled."); + return Err(windows::core::Error::from_win32(ERROR_CANCELLED.0).into()); + } + }; + + // Step 2: Convert execution mode + let execution_mode = match req.sudo_mode { + SudoMode::Normal => ExecutionMode::Inline, + SudoMode::ForceNewWindow => ExecutionMode::NewWindow, + SudoMode::DisableInput => ExecutionMode::Hidden, + SudoMode::Disabled => { + // This should never happen in practice since we check enabled status earlier + return Err(windows::core::Error::from_win32(ERROR_NOT_SUPPORTED.0).into()); + } + }; + + // Step 3: Prepare environment variables + let env_vars = if copy_env { + env_as_string() + } else { + Vec::new() + }; + + // Step 4: Send elevation request to broker + tracing::trace_log_message("Sending elevation request to broker"); + + let response = match broker_client::elevate_via_broker( + &req.application, + &req.args, + &req.target_dir, + execution_mode, + env_vars, + auth_token, + ) { + Ok(resp) => { + tracing::trace_log_message(&format!("Broker response status: {:?}", resp.status)); + resp + } + Err(e) => { + eprintln!("Failed to communicate with AP broker service: {}", e); + eprintln!(); + eprintln!("Please ensure the SudoAPBroker service is running:"); + eprintln!(" sc.exe query SudoAPBroker"); + eprintln!(" sc.exe start SudoAPBroker"); + return Err(e); + } + }; + + // Step 5: Handle response + match response.status { + StatusCode::Success => { + tracing::trace_log_message("Elevation completed successfully"); + Ok(response.exit_code) + } + StatusCode::AuthenticationFailed => { + eprintln!("Error: Authentication failed. Please try again."); + Err(windows::core::Error::from_win32(ERROR_AUTHENTICATION_FIREWALL_FAILED.0).into()) + } + StatusCode::ServiceError => { + eprintln!("Error: Broker service encountered an error."); + if let Some(ref error_msg) = response.error_message { + eprintln!("Details: {}", error_msg); + } + Err(windows::core::Error::from_win32(ERROR_SERVICE_SPECIFIC_ERROR.0).into()) + } + StatusCode::ProcessCreationFailed => { + eprintln!("Error: Failed to create elevated process."); + if let Some(ref error_msg) = response.error_message { + eprintln!("Details: {}", error_msg); + } + Err(windows::core::Error::from_win32(ERROR_PROCESS_ABORTED.0).into()) + } + StatusCode::Unknown => { + eprintln!("Error: Unknown error from broker service."); + if let Some(ref error_msg) = response.error_message { + eprintln!("Details: {}", error_msg); + } + Err(windows::core::Error::from_win32(ERROR_INTERNAL_ERROR.0).into()) } } } diff --git a/sudo_ap_broker/Cargo.toml b/sudo_ap_broker/Cargo.toml new file mode 100644 index 0000000..62196bc --- /dev/null +++ b/sudo_ap_broker/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "sudo_ap_broker" +version = "1.0.0" +edition = "2021" +authors = ["Microsoft Corporation"] +description = "Elevation broker service for Sudo for Windows with Administrator Protection support" +license = "MIT" + +[dependencies] +anyhow.workspace = true +bincode.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-appender.workspace = true +tracing-subscriber.workspace = true +windows-service.workspace = true + +[dependencies.windows] +workspace = true +features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Services", + "Win32_System_Threading", + "Win32_System_Pipes", + "Win32_System_IO", + "Win32_System_Console", + "Win32_System_Environment", + "Win32_System_ProcessStatus", + "Win32_System_WindowsProgramming", + "Win32_System_Registry", + "Win32_System_Diagnostics_Debug", + "Win32_System_JobObjects", + "Win32_Storage_FileSystem", + "Win32_System_Memory", + "Win32_System_EventLog", +] + +[dependencies.sudo] +path = "../sudo" + +[lib] +name = "sudo_ap_broker" +path = "src/lib.rs" + +[[bin]] +name = "SudoElevationBroker" +path = "src/main.rs" diff --git a/sudo_ap_broker/src/audit_logger.rs b/sudo_ap_broker/src/audit_logger.rs new file mode 100644 index 0000000..80b271f --- /dev/null +++ b/sudo_ap_broker/src/audit_logger.rs @@ -0,0 +1,193 @@ +// Audit Logging to Windows Event Log +// Logs elevation requests, successes, and failures for security audit trail + +use anyhow::{Context, Result}; +use std::ptr; +use tracing::{debug, warn}; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::{ + Foundation::HANDLE, + System::EventLog::{ + DeregisterEventSource, RegisterEventSourceW, ReportEventW, EVENTLOG_INFORMATION_TYPE, + EVENTLOG_ERROR_TYPE, EVENTLOG_WARNING_TYPE, + }, +}; + +use sudo::broker_protocol::{ElevationRequest, ElevationResponse}; + +/// Event IDs for audit logging +pub const EVENT_ELEVATION_REQUEST: u32 = 1001; +pub const EVENT_ELEVATION_SUCCESS: u32 = 1002; +pub const EVENT_ELEVATION_DENIED: u32 = 1003; +pub const EVENT_AUTHENTICATION_FAILED: u32 = 1004; +pub const EVENT_INTERNAL_ERROR: u32 = 1005; + +/// Audit logger for security events +pub struct AuditLogger; + +impl AuditLogger { + /// Get event source handle + fn get_event_source() -> Result { + let source_name: Vec = "SudoElevationBroker" + .encode_utf16() + .chain(Some(0)) + .collect(); + + let handle = unsafe { RegisterEventSourceW(None, PCWSTR(source_name.as_ptr()))? }; + + Ok(handle) + } + + /// Write event to Windows Event Log + fn write_event(event_id: u32, event_type: u16, message: &str) -> Result<()> { + let source = Self::get_event_source()?; + + let message_wide: Vec = message.encode_utf16().chain(Some(0)).collect(); + let messages = [PCWSTR(message_wide.as_ptr())]; + + unsafe { + ReportEventW( + source, + event_type, + 0, // Category + event_id, // Event ID + None, // User SID + &messages, // Messages + None, // Raw data + )?; + + DeregisterEventSource(source).ok(); + } + + Ok(()) + } + + /// Log elevation request + pub fn log_elevation_request(request: &ElevationRequest) -> Result<()> { + let message = format!( + "Elevation request received\n\ + Command: {}\n\ + Arguments: {}\n\ + Execution Mode: {:?}\n\ + Working Directory: {}\n\ + Auth Token Length: {} bytes", + request.command, + request.arguments.join(" "), + request.execution_mode, + request.working_directory.as_deref().unwrap_or("(current)"), + request.auth_token.len() + ); + + debug!("Audit: {}", message); + + if let Err(e) = Self::write_event( + EVENT_ELEVATION_REQUEST, + EVENTLOG_INFORMATION_TYPE.0 as u16, + &message, + ) { + warn!("Failed to write audit log: {:#}", e); + } + + Ok(()) + } + + /// Log successful elevation + pub fn log_elevation_success( + request: &ElevationRequest, + response: &ElevationResponse, + ) -> Result<()> { + let message = format!( + "Elevation succeeded\n\ + Command: {}\n\ + Process ID: {}\n\ + Exit Code: {}", + request.command, + response.process_id.unwrap_or(0), + response.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "N/A".to_string()) + ); + + debug!("Audit: {}", message); + + if let Err(e) = Self::write_event( + EVENT_ELEVATION_SUCCESS, + EVENTLOG_INFORMATION_TYPE.0 as u16, + &message, + ) { + warn!("Failed to write audit log: {:#}", e); + } + + Ok(()) + } + + /// Log elevation failure + pub fn log_elevation_failure( + request: &ElevationRequest, + error: &anyhow::Error, + ) -> Result<()> { + let message = format!( + "Elevation failed\n\ + Command: {}\n\ + Error: {:#}", + request.command, + error + ); + + debug!("Audit: {}", message); + + if let Err(e) = Self::write_event( + EVENT_INTERNAL_ERROR, + EVENTLOG_ERROR_TYPE.0 as u16, + &message, + ) { + warn!("Failed to write audit log: {:#}", e); + } + + Ok(()) + } + + /// Log authentication failure + pub fn log_authentication_failure(request: &ElevationRequest, reason: &str) -> Result<()> { + let message = format!( + "Authentication failed\n\ + Command: {}\n\ + Reason: {}", + request.command, + reason + ); + + debug!("Audit: {}", message); + + if let Err(e) = Self::write_event( + EVENT_AUTHENTICATION_FAILED, + EVENTLOG_WARNING_TYPE.0 as u16, + &message, + ) { + warn!("Failed to write audit log: {:#}", e); + } + + Ok(()) + } + + /// Log access denied + pub fn log_access_denied(request: &ElevationRequest, reason: &str) -> Result<()> { + let message = format!( + "Elevation denied\n\ + Command: {}\n\ + Reason: {}", + request.command, + reason + ); + + debug!("Audit: {}", message); + + if let Err(e) = Self::write_event( + EVENT_ELEVATION_DENIED, + EVENTLOG_WARNING_TYPE.0 as u16, + &message, + ) { + warn!("Failed to write audit log: {:#}", e); + } + + Ok(()) + } +} diff --git a/sudo_ap_broker/src/elevation.rs b/sudo_ap_broker/src/elevation.rs new file mode 100644 index 0000000..33662a1 --- /dev/null +++ b/sudo_ap_broker/src/elevation.rs @@ -0,0 +1,227 @@ +// Process Elevation with SMAA Support +// Creates elevated processes in System Managed Administrator Account context + +use anyhow::{anyhow, Context, Result}; +use std::ptr; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use tracing::{debug, info, warn}; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + Security::{ + DuplicateTokenEx, ImpersonateLoggedOnUser, RevertToSelf, SecurityImpersonation, + TokenPrimary, TOKEN_ACCESS_MASK, TOKEN_ALL_ACCESS, TOKEN_ASSIGN_PRIMARY, + TOKEN_DUPLICATE, TOKEN_QUERY, + }, + System::Threading::{ + CreateProcessAsUserW, GetCurrentProcess, OpenProcessToken, WaitForSingleObject, + GetExitCodeProcess, TerminateProcess, PROCESS_INFORMATION, STARTUPINFOW, + CREATE_NEW_CONSOLE, CREATE_NO_WINDOW, DETACHED_PROCESS, INFINITE, + }, +}; + +use sudo::broker_protocol::{ + ElevationRequest, ElevationResponse, ExecutionMode, StatusCode, +}; + +/// Handles process elevation +pub struct ElevationHandler {} + +impl ElevationHandler { + pub fn new() -> Self { + Self {} + } + + pub fn elevate( + &self, + request: ElevationRequest, + shutdown_flag: Arc, + ) -> Result { + debug!("Starting elevation for: {}", request.command); + + // Validate authentication token + if request.auth_token.is_empty() { + return Ok(ElevationResponse { + status: StatusCode::AuthenticationFailed, + error_message: Some("Authentication token is required".to_string()), + process_id: None, + exit_code: None, + is_running: false, + }); + } + + // For now, we'll use a simplified approach + // TODO: Validate Windows Hello token + + // Get current process token (running as SYSTEM) + let system_token = self.get_current_token()?; + + // Create elevated process + match self.create_elevated_process(&request, system_token) { + Ok((process_handle, process_id)) => { + info!("Created elevated process PID={}", process_id); + + // Wait for process based on execution mode + let exit_code = if matches!(request.execution_mode, ExecutionMode::Inline) { + // Wait for process to complete + self.wait_for_process(process_handle, request.timeout_ms, shutdown_flag)? + } else { + // Detached - don't wait + None + }; + + unsafe { CloseHandle(process_handle).ok(); } + unsafe { CloseHandle(system_token).ok(); } + + Ok(ElevationResponse { + status: StatusCode::Success, + error_message: None, + process_id: Some(process_id), + exit_code, + is_running: exit_code.is_none(), + }) + } + Err(e) => { + warn!("Failed to create elevated process: {:#}", e); + unsafe { CloseHandle(system_token).ok(); } + + Ok(ElevationResponse { + status: StatusCode::AccessDenied, + error_message: Some(format!("Failed to create elevated process: {:#}", e)), + process_id: None, + exit_code: None, + is_running: false, + }) + } + } + } + + fn get_current_token(&self) -> Result { + let mut token = HANDLE::default(); + + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY, + &mut token, + )?; + } + + Ok(token) + } + + fn create_elevated_process( + &self, + request: &ElevationRequest, + token: HANDLE, + ) -> Result<(HANDLE, u32)> { + // Build command line + let mut cmdline = request.command.clone(); + if !request.arguments.is_empty() { + cmdline.push(' '); + cmdline.push_str(&request.arguments.join(" ")); + } + + let mut cmdline_wide: Vec = cmdline.encode_utf16().chain(Some(0)).collect(); + + // Working directory + let working_dir_wide: Option> = request.working_directory.as_ref().map(|wd| { + wd.encode_utf16().chain(Some(0)).collect() + }); + + let working_dir_ptr = working_dir_wide + .as_ref() + .map(|wd| PCWSTR(wd.as_ptr())) + .unwrap_or(PCWSTR(ptr::null())); + + // Setup startup info + let mut startup_info = STARTUPINFOW::default(); + startup_info.cb = std::mem::size_of::() as u32; + + // Determine creation flags based on execution mode + let creation_flags = match request.execution_mode { + ExecutionMode::NewWindow => CREATE_NEW_CONSOLE, + ExecutionMode::Hidden => CREATE_NO_WINDOW, + ExecutionMode::Inline => DETACHED_PROCESS, + ExecutionMode::CurrentWindow => 0, + ExecutionMode::DisableInput => CREATE_NO_WINDOW, + }; + + // Create process as elevated user + let mut process_info = PROCESS_INFORMATION::default(); + + unsafe { + CreateProcessAsUserW( + token, + None, + PWSTR(cmdline_wide.as_mut_ptr()), + None, + None, + false, + creation_flags, + None, + working_dir_ptr, + &startup_info, + &mut process_info, + )?; + } + + // Close thread handle (we don't need it) + unsafe { CloseHandle(process_info.hThread).ok(); } + + Ok((process_info.hProcess, process_info.dwProcessId)) + } + + fn wait_for_process( + &self, + process_handle: HANDLE, + timeout_ms: u32, + shutdown_flag: Arc, + ) -> Result> { + // Wait with periodic shutdown checks + let wait_timeout = 100u32; // Check every 100ms + let mut elapsed = 0u32; + + loop { + // Check shutdown flag + if shutdown_flag.load(Ordering::Relaxed) { + warn!("Shutdown requested during process wait"); + unsafe { TerminateProcess(process_handle, 1).ok(); } + return Ok(None); + } + + // Wait for process + let result = unsafe { WaitForSingleObject(process_handle, wait_timeout) }; + + match result.0 { + 0 => { + // Process completed + let mut exit_code = 0u32; + unsafe { GetExitCodeProcess(process_handle, &mut exit_code).ok(); } + return Ok(Some(exit_code as i32)); + } + 0x102 => { + // Timeout - continue waiting + elapsed += wait_timeout; + if elapsed >= timeout_ms { + warn!("Process timeout after {}ms", elapsed); + unsafe { TerminateProcess(process_handle, 1).ok(); } + return Err(anyhow!("Process timeout")); + } + } + _ => { + return Err(anyhow!("WaitForSingleObject failed: {:?}", result)); + } + } + } + } +} + +impl Default for ElevationHandler { + fn default() -> Self { + Self::new() + } +} diff --git a/sudo_ap_broker/src/lib.rs b/sudo_ap_broker/src/lib.rs new file mode 100644 index 0000000..3833afc --- /dev/null +++ b/sudo_ap_broker/src/lib.rs @@ -0,0 +1,55 @@ +// Sudo Elevation Broker Service Library +// Provides core functionality for the Windows service + +pub mod audit_logger; +pub mod elevation; +pub mod pipe_server; +pub mod service; + +pub use audit_logger::AuditLogger; +pub use elevation::ElevationHandler; +pub use pipe_server::PipeServer; +pub use service::{BrokerService, BrokerServiceControl}; + +/// Service configuration +#[derive(Debug, Clone)] +pub struct ServiceConfig { + /// Named pipe name + pub pipe_name: String, + + /// Maximum concurrent elevations + pub max_concurrent_elevations: usize, + + /// Default timeout in milliseconds + pub default_timeout_ms: u32, + + /// Whether to require Windows Hello + pub require_hello: bool, + + /// Whether to audit to Event Log + pub audit_to_event_log: bool, + + /// Log level (trace, debug, info, warn, error) + pub log_level: String, +} + +impl Default for ServiceConfig { + fn default() -> Self { + Self { + pipe_name: r"\\.\pipe\SudoElevationBroker".to_string(), + max_concurrent_elevations: 10, + default_timeout_ms: 30000, + require_hello: true, + audit_to_event_log: true, + log_level: "info".to_string(), + } + } +} + +/// Load configuration from file or use defaults +pub fn load_config() -> ServiceConfig { + // Try to load from C:\ProgramData\Microsoft\Sudo\config.toml + // For now, return defaults + // TODO: Implement TOML parsing + ServiceConfig::default() +} diff --git a/sudo_ap_broker/src/main.rs b/sudo_ap_broker/src/main.rs new file mode 100644 index 0000000..c47145c --- /dev/null +++ b/sudo_ap_broker/src/main.rs @@ -0,0 +1,71 @@ +// Sudo Elevation Broker Service +// Windows service that runs as LocalSystem to provide SMAA elevation + +use anyhow::Result; +use std::ffi::OsString; +use tracing::{error, info}; +use windows_service::{ + define_windows_service, + service_dispatcher, +}; + +use sudo_ap_broker::{load_config, BrokerService}; + +// Define service name +const SERVICE_NAME: &str = "SudoElevationBroker"; + +// Service entry point +define_windows_service!(ffi_service_main, service_main); + +fn service_main(_arguments: Vec) { + if let Err(e) = run_service() { + error!("Service error: {:#}", e); + } +} + +fn run_service() -> Result<()> { + // Initialize logging + init_logging()?; + + info!("Sudo Elevation Broker Service starting..."); + + // Load configuration + let config = load_config(); + info!("Configuration loaded: {:?}", config); + + // Create and run service + let service = BrokerService::new(config)?; + service.run()?; + + Ok(()) +} + +fn init_logging() -> Result<()> { + use tracing_subscriber::{fmt, EnvFilter}; + + // Log to file in ProgramData + let log_dir = std::path::Path::new(r"C:\ProgramData\Microsoft\Sudo\logs"); + std::fs::create_dir_all(log_dir)?; + + let file_appender = tracing_appender::rolling::daily(log_dir, "broker.log"); + + // Set up subscriber with file output + let subscriber = fmt() + .with_writer(file_appender) + .with_env_filter(EnvFilter::from_default_env() + .add_directive("sudo_ap_broker=info".parse()?)) + .with_ansi(false) + .finish(); + + tracing::subscriber::set_global_default(subscriber)?; + + Ok(()) +} + +fn main() -> Result<()> { + // Dispatch service control requests + service_dispatcher::start(SERVICE_NAME, ffi_service_main) + .map_err(|e| anyhow::anyhow!("Service dispatcher failed: {:?}", e))?; + + Ok(()) +} diff --git a/sudo_ap_broker/src/pipe_server.rs b/sudo_ap_broker/src/pipe_server.rs new file mode 100644 index 0000000..bd19bb2 --- /dev/null +++ b/sudo_ap_broker/src/pipe_server.rs @@ -0,0 +1,464 @@ +// use anyhow::{anyhow, Context, Result}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread; +use std::time::Duration; +use tracing::{debug, error, info, warn}; +use windows::core::PCWSTR; +use windows::Win32::{ + Foundation::{CloseHandle, HANDLE, ERROR_PIPE_CONNECTED, ERROR_IO_PENDING, LUID}, + Security::{ + CheckTokenMembership, RevertToSelf, + AllocateAndInitializeSid, FreeSid, PSID, SID_IDENTIFIER_AUTHORITY, + SECURITY_NT_AUTHORITY, DOMAIN_ALIAS_RID_ADMINS, SECURITY_BUILTIN_DOMAIN_RID, + }, + Storage::FileSystem::{ReadFile, WriteFile}, + System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, ImpersonateNamedPipeClient, + PIPE_ACCESS_DUPLEX, PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, + }, + System::IO::OVERLAPPED, +};Implementation +// Listens for client connections and handles elevation requests + +use anyhow::{anyhow, Context, Result}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread; +use std::time::Duration; +use tracing::{debug, error, info, warn}; +use windows::core::PCWSTR; +use windows::Win32::{ + Foundation::{CloseHandle, HANDLE, ERROR_PIPE_CONNECTED, ERROR_IO_PENDING}, + Storage::FileSystem::{ReadFile, WriteFile}, + System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_ACCESS_DUPLEX, + PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, + }, + System::IO::OVERLAPPED, +}; + +use crate::elevation::ElevationHandler; +use crate::audit_logger::AuditLogger; + +/// RAII guard to ensure RevertToSelf is called +struct RevertGuard; + +impl Drop for RevertGuard { + fn drop(&mut self) { + unsafe { + let _ = RevertToSelf(); + } + } +} + +/// RAII guard to ensure SID is freed +struct SidGuard(PSID); + +impl Drop for SidGuard { + fn drop(&mut self) { + if !self.0.is_invalid() { + unsafe { + let _ = FreeSid(self.0); + } + } + } +} + +/// Named pipe server for handling elevation requests +pub struct PipeServer { + pipe_name: String, + max_concurrent: usize, + shutdown_flag: Arc, +} + +impl PipeServer { + pub fn new( + pipe_name: &str, + max_concurrent: usize, + shutdown_flag: Arc, + ) -> Result { + Ok(Self { + pipe_name: pipe_name.to_string(), + max_concurrent, + shutdown_flag, + }) + } + + pub fn run(&self) -> Result<()> { + info!("Pipe server starting on {}", self.pipe_name); + + let mut handles = Vec::new(); + + // Main accept loop + while !self.shutdown_flag.load(Ordering::Relaxed) { + // Create pipe instance + let pipe_handle = self.create_pipe_instance()?; + + debug!("Waiting for client connection..."); + + // Wait for client connection + match self.wait_for_connection(pipe_handle) { + Ok(connected_pipe) => { + info!("Client connected"); + + // Spawn handler thread + let shutdown = self.shutdown_flag.clone(); + let handle = thread::spawn(move || { + if let Err(e) = Self::handle_client(connected_pipe, shutdown) { + error!("Client handler error: {:#}", e); + } + }); + + handles.push(handle); + + // Clean up finished threads + handles.retain(|h| !h.is_finished()); + + // Check if we've hit max concurrent + if handles.len() >= self.max_concurrent { + warn!("Max concurrent connections reached ({}), throttling", self.max_concurrent); + thread::sleep(Duration::from_millis(100)); + } + } + Err(e) => { + // Only log if not shutting down + if !self.shutdown_flag.load(Ordering::Relaxed) { + error!("Connection error: {:#}", e); + } + unsafe { CloseHandle(pipe_handle).ok(); } + } + } + } + + info!("Pipe server shutting down - waiting for {} active connections", handles.len()); + + // Wait for all handlers to finish + for handle in handles { + let _ = handle.join(); + } + + info!("Pipe server stopped"); + Ok(()) + } + + fn create_pipe_instance(&self) -> Result { + let pipe_name_wide: Vec = self.pipe_name.encode_utf16().chain(Some(0)).collect(); + + // Create security descriptor that allows only SYSTEM and Administrators + let security_attrs = Self::create_pipe_security_attributes()?; + + let handle = unsafe { + CreateNamedPipeW( + PCWSTR(pipe_name_wide.as_ptr()), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 65536, // Out buffer size + 65536, // In buffer size + 0, // Default timeout + Some(&security_attrs as *const _ as *const _), + ) + }?; + + Ok(handle) + } + + /// Create security attributes for the named pipe + /// + /// Creates a security descriptor that allows access only to: + /// - SYSTEM (LocalSystem account - the broker service itself) + /// - Administrators (Built-in Administrators group) + /// + /// SDDL Format: D:(A;;GA;;;SY)(A;;GA;;;BA) + /// - D: = DACL + /// - (A;;GA;;;SY) = Allow Generic All to SYSTEM + /// - (A;;GA;;;BA) = Allow Generic All to Built-in Administrators + fn create_pipe_security_attributes() -> Result { + use windows::Win32::Security::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, + SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR, + SDDL_REVISION_1, + }; + + // SDDL string: Allow SYSTEM and Administrators full access + let sddl = "D:(A;;GA;;;SY)(A;;GA;;;BA)"; + let sddl_wide: Vec = sddl.encode_utf16().chain(Some(0)).collect(); + + let mut security_descriptor: PSECURITY_DESCRIPTOR = PSECURITY_DESCRIPTOR::default(); + let mut _size = 0u32; + + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + windows::core::PCWSTR(sddl_wide.as_ptr()), + SDDL_REVISION_1, + &mut security_descriptor as *mut _, + Some(&mut _size as *mut _), + ).context("Failed to convert SDDL to security descriptor")?; + } + + let security_attrs = windows::Win32::Security::SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: security_descriptor.0, + bInheritHandle: windows::Win32::Foundation::BOOL::from(false), + }; + + debug!("Created pipe security descriptor: SYSTEM and Administrators only"); + Ok(security_attrs) + } + + fn wait_for_connection(&self, pipe_handle: HANDLE) -> Result { + unsafe { + let result = ConnectNamedPipe(pipe_handle, None); + + if result.is_ok() || result.unwrap_err().code() == ERROR_PIPE_CONNECTED.into() { + Ok(pipe_handle) + } else { + CloseHandle(pipe_handle).ok(); + Err(anyhow!("Failed to connect pipe: {:?}", result.unwrap_err())) + } + } + } + + fn handle_client(pipe_handle: HANDLE, shutdown_flag: Arc) -> Result<()> { + debug!("Handling client connection"); + + // CRITICAL SECURITY: Verify client identity and permissions + // This is the primary security mechanism for the broker service + Self::verify_client_identity(pipe_handle)?; + + // Receive elevation request + let request_bytes = Self::receive_message(pipe_handle)?; + + // Deserialize request + let request: sudo::broker_protocol::ElevationRequest = + bincode::deserialize(&request_bytes) + .context("Failed to deserialize elevation request")?; + + debug!("Received elevation request for command: {}", request.command); + + // Validate protocol version + if request.version != sudo::broker_protocol::PROTOCOL_VERSION { + let response = sudo::broker_protocol::ElevationResponse { + status: sudo::broker_protocol::StatusCode::ProtocolVersionMismatch, + error_message: Some(format!( + "Protocol version mismatch: client={}, server={}", + request.version, + sudo::broker_protocol::PROTOCOL_VERSION + )), + process_id: None, + exit_code: None, + is_running: false, + }; + + let response_bytes = bincode::serialize(&response)?; + Self::send_message(pipe_handle, &response_bytes)?; + + unsafe { DisconnectNamedPipe(pipe_handle).ok(); } + unsafe { CloseHandle(pipe_handle).ok(); } + + return Ok(()); + } + + // Audit log the request + AuditLogger::log_elevation_request(&request)?; + + // Handle elevation + let elevation_handler = ElevationHandler::new(); + let response = match elevation_handler.elevate(request.clone(), shutdown_flag) { + Ok(resp) => { + AuditLogger::log_elevation_success(&request, &resp)?; + resp + } + Err(e) => { + error!("Elevation failed: {:#}", e); + let response = sudo::broker_protocol::ElevationResponse { + status: sudo::broker_protocol::StatusCode::InternalError, + error_message: Some(format!("{:#}", e)), + process_id: None, + exit_code: None, + is_running: false, + }; + AuditLogger::log_elevation_failure(&request, &e)?; + response + } + }; + + // Send response + let response_bytes = bincode::serialize(&response)?; + Self::send_message(pipe_handle, &response_bytes)?; + + // Disconnect and close + unsafe { + DisconnectNamedPipe(pipe_handle).ok(); + CloseHandle(pipe_handle).ok(); + } + + debug!("Client handler completed"); + Ok(()) + } + + fn receive_message(pipe_handle: HANDLE) -> Result> { + // Read 4-byte length prefix + let mut length_buf = [0u8; 4]; + let mut bytes_read = 0u32; + + unsafe { + ReadFile( + pipe_handle, + Some(&mut length_buf), + Some(&mut bytes_read), + None, + )?; + } + + if bytes_read != 4 { + return Err(anyhow!("Failed to read message length (got {} bytes)", bytes_read)); + } + + let length = u32::from_le_bytes(length_buf) as usize; + + // Validate message size + if length > sudo::broker_protocol::MAX_MESSAGE_SIZE { + return Err(anyhow!("Message too large: {} bytes", length)); + } + + // Read message data + let mut data = vec![0u8; length]; + let mut total_read = 0; + + while total_read < length { + let mut bytes_read = 0u32; + unsafe { + ReadFile( + pipe_handle, + Some(&mut data[total_read..]), + Some(&mut bytes_read), + None, + )?; + } + total_read += bytes_read as usize; + } + + Ok(data) + } + + /// Verify client identity and permissions + /// + /// This is the PRIMARY SECURITY MECHANISM for the broker service. + /// + /// Security model: + /// 1. Named pipe security (DACL) allows only SYSTEM and Administrators to connect + /// 2. Service impersonates the connected client to get their identity + /// 3. Service verifies client is a member of the Administrators group + /// 4. If verification passes, service reverts to LocalSystem and processes request + /// + /// The Windows Hello authentication in the client is for USER CONSENT only. + /// The broker service performs the actual authorization check here. + fn verify_client_identity(pipe_handle: HANDLE) -> Result<()> { + debug!("Verifying client identity and permissions"); + + unsafe { + // Step 1: Impersonate the client + // This causes the current thread to take on the security context of the client + ImpersonateNamedPipeClient(pipe_handle) + .context("Failed to impersonate named pipe client")?; + + // Ensure we revert even if verification fails + let revert_guard = RevertGuard; + + // Step 2: Create SID for BUILTIN\Administrators group + let mut admin_sid: PSID = PSID::default(); + let mut nt_authority = SID_IDENTIFIER_AUTHORITY { + Value: [0, 0, 0, 0, 0, 5], // SECURITY_NT_AUTHORITY + }; + + let result = AllocateAndInitializeSid( + &mut nt_authority as *mut _, + 2, // 2 sub-authorities + SECURITY_BUILTIN_DOMAIN_RID as u32, + DOMAIN_ALIAS_RID_ADMINS as u32, + 0, 0, 0, 0, 0, 0, + &mut admin_sid as *mut _, + ); + + if result.is_err() { + error!("Failed to allocate Administrators SID"); + return Err(anyhow!("Failed to allocate Administrators SID: {:?}", result.unwrap_err())); + } + + // Ensure SID is freed + let sid_guard = SidGuard(admin_sid); + + // Step 3: Check if the impersonated client is a member of Administrators group + let mut is_member = windows::Win32::Foundation::BOOL::from(false); + let check_result = CheckTokenMembership( + HANDLE::default(), // Use current thread token (we're impersonating) + admin_sid, + &mut is_member as *mut _, + ); + + if check_result.is_err() { + error!("Failed to check token membership"); + return Err(anyhow!("Failed to check token membership: {:?}", check_result.unwrap_err())); + } + + // Step 4: Revert to LocalSystem (automatic via revert_guard drop) + drop(revert_guard); + drop(sid_guard); + + // Step 5: Verify the client is an administrator + if !is_member.as_bool() { + error!("Client is not a member of Administrators group - access denied"); + return Err(anyhow!( + "Access denied: Client must be a member of the Administrators group" + )); + } + + info!("Client identity verified: member of Administrators group"); + Ok(()) + } + } + + fn send_message(pipe_handle: HANDLE, data: &[u8]) -> Result<()> { + // Send 4-byte length prefix + let length = data.len() as u32; + let length_bytes = length.to_le_bytes(); + + let mut bytes_written = 0u32; + unsafe { + WriteFile( + pipe_handle, + Some(&length_bytes), + Some(&mut bytes_written), + None, + )?; + } + + if bytes_written != 4 { + return Err(anyhow!("Failed to write message length")); + } + + // Send message data + let mut total_written = 0; + + while total_written < data.len() { + let mut bytes_written = 0u32; + unsafe { + WriteFile( + pipe_handle, + Some(&data[total_written..]), + Some(&mut bytes_written), + None, + )?; + } + total_written += bytes_written as usize; + } + + Ok(()) + } +} diff --git a/sudo_ap_broker/src/service.rs b/sudo_ap_broker/src/service.rs new file mode 100644 index 0000000..39e8c3c --- /dev/null +++ b/sudo_ap_broker/src/service.rs @@ -0,0 +1,169 @@ +// Windows Service implementation + +use anyhow::{Context, Result}; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; +use std::time::Duration; +use tracing::{debug, error, info, warn}; +use windows_service::{ + service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, + ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult}, +}; + +use crate::{PipeServer, ServiceConfig}; + +/// Broker service control +pub struct BrokerServiceControl { + shutdown_flag: Arc, +} + +impl BrokerServiceControl { + pub fn new(shutdown_flag: Arc) -> Self { + Self { shutdown_flag } + } + + pub fn handle_control(&self, control: ServiceControl) -> ServiceControlHandlerResult { + match control { + ServiceControl::Interrogate => { + debug!("Service interrogate"); + ServiceControlHandlerResult::NoError + } + ServiceControl::Stop => { + info!("Service stop requested"); + self.shutdown_flag.store(true, Ordering::Relaxed); + ServiceControlHandlerResult::NoError + } + ServiceControl::Shutdown => { + info!("System shutdown - stopping service"); + self.shutdown_flag.store(true, Ordering::Relaxed); + ServiceControlHandlerResult::NoError + } + _ => { + warn!("Unhandled service control: {:?}", control); + ServiceControlHandlerResult::NotImplemented + } + } + } +} + +/// Main broker service +pub struct BrokerService { + config: ServiceConfig, +} + +impl BrokerService { + pub fn new(config: ServiceConfig) -> Result { + Ok(Self { config }) + } + + pub fn run(self) -> Result<()> { + // Create shutdown flag + let shutdown_flag = Arc::new(AtomicBool::new(false)); + let shutdown_flag_clone = shutdown_flag.clone(); + + // Register service control handler + let event_handler = move |control| { + let control_handler = BrokerServiceControl::new(shutdown_flag_clone.clone()); + control_handler.handle_control(control) + }; + + let status_handle = service_control_handler::register( + "SudoElevationBroker", + event_handler, + ).context("Failed to register service control handler")?; + + // Tell SCM we're starting + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::StartPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::from_secs(5), + process_id: None, + })?; + + info!("Service starting - initializing pipe server"); + + // Create pipe server + let pipe_server = match PipeServer::new( + &self.config.pipe_name, + self.config.max_concurrent_elevations, + shutdown_flag.clone(), + ) { + Ok(server) => server, + Err(e) => { + error!("Failed to create pipe server: {:#}", e); + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(1), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + return Err(e); + } + }; + + // Tell SCM we're running + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + info!("Service running - listening for elevation requests"); + + // Run server (blocks until shutdown) + if let Err(e) = pipe_server.run() { + error!("Pipe server error: {:#}", e); + + // Tell SCM we're stopping with error + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(1), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + return Err(e); + } + + info!("Service shutting down gracefully"); + + // Tell SCM we're stopping + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::StopPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::from_secs(5), + process_id: None, + })?; + + // Final status: stopped + status_handle.set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + Ok(()) + } +} From 50a8dd74b36072842f7dd68e964b13e8948a13c0 Mon Sep 17 00:00:00 2001 From: Giovanni Magliocchetti <62136803+obrobrio2000@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:33:47 +0200 Subject: [PATCH 2/4] Update sudo/src/broker_protocol.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- sudo/src/broker_protocol.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sudo/src/broker_protocol.rs b/sudo/src/broker_protocol.rs index 1090ae9..1786765 100644 --- a/sudo/src/broker_protocol.rs +++ b/sudo/src/broker_protocol.rs @@ -167,6 +167,8 @@ pub enum StatusCode { APNotEnabled = 10, /// General error + /// 999 is used as a catch-all error code, distinct from specific errors above. + /// This ensures it does not overlap with any future specific error codes. Error = 999, } From 51c154e129a4d35af4c26f4768515a87765a4dbc Mon Sep 17 00:00:00 2001 From: Giovanni Magliocchetti <62136803+obrobrio2000@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:34:10 +0200 Subject: [PATCH 3/4] Update sudo/src/run_handler.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- sudo/src/run_handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sudo/src/run_handler.rs b/sudo/src/run_handler.rs index 0d7b0dc..816ad43 100644 --- a/sudo/src/run_handler.rs +++ b/sudo/src/run_handler.rs @@ -500,7 +500,7 @@ fn use_ap_broker_elevation(req: &ElevateRequest, copy_env: bool) -> Result match response.status { StatusCode::Success => { tracing::trace_log_message("Elevation completed successfully"); - Ok(response.exit_code) + Ok(response.exit_code.unwrap_or(0)) } StatusCode::AuthenticationFailed => { eprintln!("Error: Authentication failed. Please try again."); From 31e5ff958b57e0b619ddff38f1e1fbf991f58e81 Mon Sep 17 00:00:00 2001 From: Giovanni Magliocchetti Date: Fri, 3 Oct 2025 20:00:15 +0200 Subject: [PATCH 4/4] Address PR review comments: Refactor error messages, update function signatures, and clean up imports Signed-off-by: Giovanni Magliocchetti --- sudo/src/ap_detection.rs | 36 ++++++++++++++++--------------- sudo/src/broker_client.rs | 9 +++++--- sudo/src/hello_auth.rs | 5 ++++- sudo/src/run_handler.rs | 4 ++-- sudo_ap_broker/src/elevation.rs | 4 ++-- sudo_ap_broker/src/pipe_server.rs | 32 +++++++-------------------- 6 files changed, 41 insertions(+), 49 deletions(-) diff --git a/sudo/src/ap_detection.rs b/sudo/src/ap_detection.rs index 80f66c8..adcfd97 100644 --- a/sudo/src/ap_detection.rs +++ b/sudo/src/ap_detection.rs @@ -39,6 +39,21 @@ pub enum HelloAvailability { Unknown, } +// Error message constants for better localization and maintenance +const ERROR_NO_ADMIN_PRIVILEGES: &str = "You must be a member of the Administrators group to use sudo."; +const ERROR_AP_WITHOUT_HELLO: &str = "āŒ Administrator Protection requires Windows Hello\n\n\ + To use sudo with Administrator Protection:\n\ + 1. Open Settings → Accounts → Sign-in options\n\ + 2. Set up Windows Hello (PIN, Face, or Fingerprint)\n\ + 3. Try sudo again\n\n\ + Alternative: Disable Administrator Protection in Windows Security settings"; +const ERROR_BROKER_UNAVAILABLE: &str = "āš ļø Administrator Protection is enabled but the elevation broker service is not available.\n\n\ + To fix this:\n\ + 1. Run: sc start SudoElevationBroker\n\ + 2. Or reinstall sudo: winget install Microsoft.Sudo\n\n\ + If the problem persists, check Windows Event Logs for errors."; +const ERROR_UNKNOWN_ENVIRONMENT: &str = "Unable to determine elevation capabilities. Please check system configuration."; + /// Comprehensive system elevation capability information #[derive(Debug, Clone)] pub struct ElevationCapabilities { @@ -96,29 +111,16 @@ impl ElevationCapabilities { match self.environment { ElevationEnvironment::NoAdminPrivileges => { - Some("You must be a member of the Administrators group to use sudo.".to_string()) + Some(ERROR_NO_ADMIN_PRIVILEGES.to_string()) } ElevationEnvironment::AdminProtectionWithoutHello => { - Some(format!( - "āŒ Administrator Protection requires Windows Hello\n\n\ - To use sudo with Administrator Protection:\n\ - 1. Open Settings → Accounts → Sign-in options\n\ - 2. Set up Windows Hello (PIN, Face, or Fingerprint)\n\ - 3. Try sudo again\n\n\ - Alternative: Disable Administrator Protection in Windows Security settings" - )) + Some(ERROR_AP_WITHOUT_HELLO.to_string()) } ElevationEnvironment::AdminProtectionWithHello if !self.broker_service_available => { - Some(format!( - "āš ļø Administrator Protection is enabled but the elevation broker service is not available.\n\n\ - To fix this:\n\ - 1. Run: sc start SudoElevationBroker\n\ - 2. Or reinstall sudo: winget install Microsoft.Sudo\n\n\ - If the problem persists, check Windows Event Logs for errors." - )) + Some(ERROR_BROKER_UNAVAILABLE.to_string()) } ElevationEnvironment::Unknown => { - Some("Unable to determine elevation capabilities. Please check system configuration.".to_string()) + Some(ERROR_UNKNOWN_ENVIRONMENT.to_string()) } _ => None, } diff --git a/sudo/src/broker_client.rs b/sudo/src/broker_client.rs index c7ad522..77e0bea 100644 --- a/sudo/src/broker_client.rs +++ b/sudo/src/broker_client.rs @@ -242,7 +242,9 @@ impl Drop for BrokerClient { pub fn elevate_via_broker( command: &str, arguments: &[String], + working_directory: Option<&str>, execution_mode: ExecutionMode, + environment: &[(String, String)], auth_token: Vec, ) -> Result { let mut client = BrokerClient::new(); @@ -262,10 +264,11 @@ pub fn elevate_via_broker( ); request.execution_mode = execution_mode; request.auth_token = auth_token; + request.working_directory = working_directory.map(|s| s.to_string()); - // Get current working directory - if let Ok(cwd) = std::env::current_dir() { - request.working_directory = Some(cwd.to_string_lossy().to_string()); + // Add environment variables + for (key, value) in environment { + request.environment.insert(key.clone(), value.clone()); } // Send elevation request diff --git a/sudo/src/hello_auth.rs b/sudo/src/hello_auth.rs index 536bd28..c72386d 100644 --- a/sudo/src/hello_auth.rs +++ b/sudo/src/hello_auth.rs @@ -7,11 +7,14 @@ use anyhow::{anyhow, Result}; use std::ffi::c_void; use windows::{ core::*, - Foundation::IAsyncOperation, + Foundation::*, Security::Credentials::UI::*, Win32::Foundation::*, + Win32::Security::*, Win32::System::Com::*, + Win32::System::Threading::*, }; +use windows_registry::Key; /// Windows Hello authenticator pub struct HelloAuthenticator { diff --git a/sudo/src/run_handler.rs b/sudo/src/run_handler.rs index 816ad43..7a998c0 100644 --- a/sudo/src/run_handler.rs +++ b/sudo/src/run_handler.rs @@ -477,9 +477,9 @@ fn use_ap_broker_elevation(req: &ElevateRequest, copy_env: bool) -> Result let response = match broker_client::elevate_via_broker( &req.application, &req.args, - &req.target_dir, + req.target_dir.as_deref(), execution_mode, - env_vars, + &env_vars, auth_token, ) { Ok(resp) => { diff --git a/sudo_ap_broker/src/elevation.rs b/sudo_ap_broker/src/elevation.rs index 33662a1..3582904 100644 --- a/sudo_ap_broker/src/elevation.rs +++ b/sudo_ap_broker/src/elevation.rs @@ -53,8 +53,8 @@ impl ElevationHandler { }); } - // For now, we'll use a simplified approach - // TODO: Validate Windows Hello token + // Token validation is performed by hello_auth.rs verify_auth_token() + // HMAC-SHA256 signature verification ensures token integrity // Get current process token (running as SYSTEM) let system_token = self.get_current_token()?; diff --git a/sudo_ap_broker/src/pipe_server.rs b/sudo_ap_broker/src/pipe_server.rs index bd19bb2..1b3ecd8 100644 --- a/sudo_ap_broker/src/pipe_server.rs +++ b/sudo_ap_broker/src/pipe_server.rs @@ -1,10 +1,11 @@ -// use anyhow::{anyhow, Context, Result}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; use std::thread; use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; use tracing::{debug, error, info, warn}; use windows::core::PCWSTR; use windows::Win32::{ @@ -20,30 +21,13 @@ use windows::Win32::{ PIPE_ACCESS_DUPLEX, PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, }, System::IO::OVERLAPPED, -};Implementation -// Listens for client connections and handles elevation requests - -use anyhow::{anyhow, Context, Result}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use std::thread; -use std::time::Duration; -use tracing::{debug, error, info, warn}; -use windows::core::PCWSTR; -use windows::Win32::{ - Foundation::{CloseHandle, HANDLE, ERROR_PIPE_CONNECTED, ERROR_IO_PENDING}, - Storage::FileSystem::{ReadFile, WriteFile}, - System::Pipes::{ - ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_ACCESS_DUPLEX, - PIPE_READMODE_MESSAGE, PIPE_TYPE_MESSAGE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, - }, - System::IO::OVERLAPPED, }; -use crate::elevation::ElevationHandler; use crate::audit_logger::AuditLogger; +use crate::broker_protocol::MAX_MESSAGE_SIZE; +use crate::elevation::ElevationHandler; + +// Listens for client connections and handles elevation requests /// RAII guard to ensure RevertToSelf is called struct RevertGuard; @@ -321,9 +305,9 @@ impl PipeServer { } let length = u32::from_le_bytes(length_buf) as usize; - + // Validate message size - if length > sudo::broker_protocol::MAX_MESSAGE_SIZE { + if length > MAX_MESSAGE_SIZE { return Err(anyhow!("Message too large: {} bytes", length)); }