diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index a00050470..20db173cf 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -129,6 +129,8 @@ jobs: -p semwright-daemon - name: Secure Windows Driver workspace grants run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_workspace --nocapture + - name: Secure Windows Driver secret grants + run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_secret --nocapture - name: Secure Windows Plugin Host round-trip run: cargo test --locked -p semwright-plugin-host --features test-tools --test windows_secure_host -- --nocapture - name: Secure Windows external MCP round-trip diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index 167f71899..9331eca61 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use semwright_driver_sdk::{ - Capability, Driver, DriverExecutionContext, DriverInterfaces, descriptor_digest, serve, - system_config_mount, workspace_mount, + Capability, Driver, DriverExecutionContext, DriverInterfaces, descriptor_digest, secret_mount, + serve, system_config_mount, workspace_mount, }; use semwright_types::{ CommandDescriptor, Error, ErrorCode, Idempotency, JobArtifact, JobProgress, Result, Risk, @@ -131,6 +131,36 @@ fn disconnect_capability() -> Capability { } } +fn secret_capability() -> Capability { + Capability { + descriptor: CommandDescriptor { + name: "driver.fixture.secret_probe".into(), + version: "1".into(), + description: "Read an owner-granted secret and probe mutation authority".into(), + input_schema: json!({"type":"object","additionalProperties":false}), + output_schema: json!({ + "type":"object", + "properties":{ + "read":{"type":"string"}, + "write_ok":{"type":"boolean"} + }, + "required":["read","write_ok"], + "additionalProperties":false + }), + requires: vec!["driver:fixture".into()], + risk: Risk::MutatingReversible, + idempotency: Idempotency::Idempotent, + timeout_ms: 2_000, + dry_run: false, + interactive_consent: false, + backends: vec!["driver:fixture".into()], + }, + aliases: vec!["secret_probe".into()], + tags: vec!["fixture".into(), "conformance".into(), "secret".into()], + object_types: vec![], + } +} + fn long_capability() -> Capability { Capability { descriptor: CommandDescriptor { @@ -188,6 +218,7 @@ impl Driver for Fixture { capability(), mount_capability(), config_capability(), + secret_capability(), long_capability(), disconnect_capability(), ]) @@ -197,6 +228,7 @@ impl Driver for Fixture { "driver.fixture.ping" => capability(), "driver.fixture.mount_probe" => mount_capability(), "driver.fixture.config_probe" => config_capability(), + "driver.fixture.secret_probe" => secret_capability(), "driver.fixture.disconnect" => disconnect_capability(), _ => { return Err(Error::new( @@ -233,6 +265,17 @@ impl Driver for Fixture { let write_ok = std::fs::write(root.join("child.txt"), b"written").is_ok(); return Ok(json!({"read":read,"write_ok":write_ok})); } + if command == "driver.fixture.secret_probe" { + if args.as_object().is_none_or(|args| !args.is_empty()) { + return Err(Error::invalid( + "fixture secret probe accepts an empty object", + )); + } + let path = secret_mount("fixture-secret")?; + let read = std::fs::read_to_string(&path)?; + let write_ok = std::fs::write(&path, b"changed").is_ok(); + return Ok(json!({"read":read,"write_ok":write_ok})); + } if command == "driver.fixture.disconnect" { if args.as_object().is_none_or(|args| !args.is_empty()) { return Err(Error::invalid("fixture disconnect accepts an empty object")); diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index a899157b3..e3e1c75b2 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -326,32 +326,40 @@ async fn wait_for_operation_cpu_budget( } fn validate_secret_source(path: &Path) -> Result<()> { - if std::fs::canonicalize(path)? != path { - return Err(Error::new( - ErrorCode::PolicyDenied, - "Driver secret source must be canonical", - )); - } - let metadata = std::fs::symlink_metadata(path)?; - if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 4096 { - return Err(Error::new( - ErrorCode::PolicyDenied, - "Driver secret source must be a small regular file", - )); + #[cfg(target_os = "windows")] + { + semwright_platform_services::verify_private_data_file(path, 4096) } - #[cfg(unix)] + + #[cfg(not(target_os = "windows"))] { - if metadata.uid() != semwright_platform_services::current_uid() - || metadata.mode() & 0o077 != 0 - || metadata.nlink() != 1 - { + if std::fs::canonicalize(path)? != path { + return Err(Error::new( + ErrorCode::PolicyDenied, + "Driver secret source must be canonical", + )); + } + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > 4096 { return Err(Error::new( - ErrorCode::PermissionDenied, - "Driver secret source must be owner-only and single-linked", + ErrorCode::PolicyDenied, + "Driver secret source must be a small regular file", )); } + #[cfg(unix)] + { + if metadata.uid() != semwright_platform_services::current_uid() + || metadata.mode() & 0o077 != 0 + || metadata.nlink() != 1 + { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Driver secret source must be owner-only and single-linked", + )); + } + } + Ok(()) } - Ok(()) } fn validate_owner_permissions( @@ -791,10 +799,10 @@ fn sandbox_spec_windows( Mount, MountClass, ResourceLimits, SandboxKind, SandboxSpec, }; - if !manifest.secrets.is_empty() || !manifest.tools.is_empty() { + if !manifest.tools.is_empty() { return Err(Error::new( ErrorCode::SandboxDenied, - "Windows secret and tool grants remain fail-closed until their source-integrity and immutable-executable contracts are proven", + "Windows tool grants remain fail-closed until their immutable-executable contract is proven", )); } if manifest.loopback_port.is_some() { @@ -852,6 +860,22 @@ fn sandbox_spec_windows( }) .collect::>>()?, ); + mounts.extend( + manifest + .secrets + .iter() + .map(|secret| { + let grant = lookup(&secret.root)?; + Ok(Mount { + source: grant.path.clone(), + class: MountClass::Secret, + logical_name: secret.name.clone(), + read_only: true, + execute: false, + }) + }) + .collect::>>()?, + ); Ok(SandboxSpec { kind: SandboxKind::Driver, staged_executable: staged.into(), diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index d7c07a26d..6ee3c0354 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -3,8 +3,8 @@ use semwright_backend_api::{Context, Provider}; use semwright_driver_host::DriverProvider; use semwright_driver_sdk::{ - ApplicationMatch, DriverInterfaces, DriverMount, DriverResources, Manifest, SystemConfigMount, - Transport, + ApplicationMatch, DriverInterfaces, DriverMount, DriverResources, DriverSecretMount, Manifest, + SystemConfigMount, Transport, }; use semwright_policy::FilesystemGrant; use sha2::{Digest, Sha256}; @@ -139,6 +139,32 @@ fn grant_all_application_packages_modify(path: &Path) { ); } +fn grant_all_application_packages_file_modify(path: &Path) { + let status = std::process::Command::new("icacls") + .arg(path) + .arg("/grant") + .arg("*S-1-15-2-1:(M)") + .status() + .expect("grant ALL APPLICATION PACKAGES file modify"); + assert!( + status.success(), + "broad application-package secret mutation grant must succeed" + ); +} + +fn grant_all_application_packages_file_read(path: &Path) { + let status = std::process::Command::new("icacls") + .arg(path) + .arg("/grant") + .arg("*S-1-15-2-1:(R)") + .status() + .expect("grant ALL APPLICATION PACKAGES file read"); + assert!( + status.success(), + "broad application-package secret read grant must succeed" + ); +} + async fn execute_mount_probe( read_only: bool, owner_write: bool, @@ -291,6 +317,150 @@ async fn secure_windows_driver_system_config_is_read_only() { assert!(!config.path().join("child.txt").exists()); } +async fn execute_secret_probe() -> (serde_json::Value, tempfile::TempDir) { + let source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); + let binary_dir = tempfile::tempdir().expect("fixture directory"); + let executable = binary_dir.path().join("driver.exe"); + std::fs::copy(&source, &executable).expect("copy driver fixture"); + harden_fixture(&executable); + + let secret_dir = tempfile::tempdir().expect("secret grant"); + let secret_path = secret_dir.path().join("secret.txt"); + std::fs::write(&secret_path, b"owner-secret").expect("write secret fixture"); + harden_fixture(&secret_path); + + let mut manifest = manifest(executable); + manifest.secrets = vec![DriverSecretMount { + root: "fixture-secret-root".into(), + name: "fixture-secret".into(), + }]; + let roots = vec![FilesystemGrant { + name: "fixture-secret-root".into(), + path: secret_path.clone(), + read: true, + write: false, + }]; + + let state = tempfile::tempdir().expect("driver state"); + let helper = std::env::current_exe().expect("current test executable"); + let provider = DriverProvider::connect(manifest, state.path(), &helper, &roots, false) + .await + .expect("Windows Driver Host secret mount"); + + let capabilities = Provider::capabilities(provider.as_ref()) + .await + .expect("driver capabilities"); + let probe = capabilities + .iter() + .find(|capability| capability.descriptor.name == "driver.fixture.secret_probe") + .expect("fixture secret capability") + .descriptor + .clone(); + let output = Provider::execute( + provider.as_ref(), + &Context { + session: "windows-secret".into(), + request_id: "windows-secret-read-only".into(), + cancellation: CancellationToken::new(), + }, + &probe, + &serde_json::json!({}), + ) + .await + .expect("secret probe through LPAC"); + + Provider::shutdown(provider.as_ref()) + .await + .expect("secret Driver Host shutdown"); + drop(binary_dir); + (output, secret_dir) +} + +#[tokio::test] +async fn secure_windows_driver_secret_is_private_and_read_only() { + let (output, secret_dir) = execute_secret_probe().await; + assert_eq!(output["read"], "owner-secret"); + assert_eq!(output["write_ok"], false); + assert_eq!( + std::fs::read(secret_dir.path().join("secret.txt")).expect("read secret after shutdown"), + b"owner-secret" + ); +} + +#[tokio::test] +async fn secure_windows_driver_secret_rejects_source_with_broad_mutation_acl() { + let source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); + let binary_dir = tempfile::tempdir().expect("fixture directory"); + let executable = binary_dir.path().join("driver.exe"); + std::fs::copy(&source, &executable).expect("copy driver fixture"); + harden_fixture(&executable); + + let secret_dir = tempfile::tempdir().expect("secret grant"); + let secret_path = secret_dir.path().join("secret.txt"); + std::fs::write(&secret_path, b"owner-secret").expect("write secret fixture"); + harden_fixture(&secret_path); + grant_all_application_packages_file_modify(&secret_path); + + let mut candidate = manifest(executable); + candidate.secrets = vec![DriverSecretMount { + root: "fixture-secret-root".into(), + name: "fixture-secret".into(), + }]; + let roots = vec![FilesystemGrant { + name: "fixture-secret-root".into(), + path: secret_path, + read: true, + write: false, + }]; + let state = tempfile::tempdir().expect("driver state"); + let helper = std::env::current_exe().expect("current test executable"); + + let error = match DriverProvider::connect(candidate, state.path(), &helper, &roots, false).await + { + Ok(provider) => { + let _ = Provider::shutdown(provider.as_ref()).await; + panic!("mutable secret source must be rejected before child launch"); + } + Err(error) => error, + }; + assert_eq!(error.code, semwright_types::ErrorCode::PermissionDenied); +} + +#[tokio::test] +async fn secure_windows_driver_rejects_nonprivate_secret_source_before_spawn() { + let source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); + let binary_dir = tempfile::tempdir().expect("fixture directory"); + let executable = binary_dir.path().join("driver.exe"); + std::fs::copy(&source, &executable).expect("copy driver fixture"); + harden_fixture(&executable); + + let directory = tempfile::tempdir().expect("secret directory"); + let secret = directory.path().join("secret.txt"); + std::fs::write(&secret, b"sensitive-secret").expect("write secret"); + harden_fixture(&secret); + grant_all_application_packages_file_read(&secret); + + let mut candidate = manifest(executable); + candidate.secrets = vec![DriverSecretMount { + root: "fixture-secret-root".into(), + name: "fixture-secret".into(), + }]; + let roots = vec![FilesystemGrant { + name: "fixture-secret-root".into(), + path: secret, + read: true, + write: false, + }]; + let state = tempfile::tempdir().expect("driver state"); + let helper = std::env::current_exe().expect("current test executable"); + let error = match DriverProvider::connect(candidate, state.path(), &helper, &roots, false).await + { + Ok(_) => panic!("non-private secret source must be rejected before spawn"), + Err(error) => error, + }; + assert_eq!(error.code, semwright_types::ErrorCode::PermissionDenied); +} + #[tokio::test] async fn secure_windows_driver_operation_cpu_budget_terminates_job() { let source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); diff --git a/crates/driver-sdk/src/lib.rs b/crates/driver-sdk/src/lib.rs index 459e8cca1..256b04635 100644 --- a/crates/driver-sdk/src/lib.rs +++ b/crates/driver-sdk/src/lib.rs @@ -75,6 +75,11 @@ pub fn system_config_mount(logical_name: &str) -> Result { runtime_mount(MountClass::SystemConfig, logical_name) } +/// Resolve an owner-granted secret file as materialized by the current platform sandbox. +pub fn secret_mount(logical_name: &str) -> Result { + runtime_mount(MountClass::Secret, logical_name) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Transport { diff --git a/crates/platform-api/src/launch.rs b/crates/platform-api/src/launch.rs index ccb5a967f..f93639b51 100644 --- a/crates/platform-api/src/launch.rs +++ b/crates/platform-api/src/launch.rs @@ -100,10 +100,18 @@ impl MaterializedMount { { return Err(Error::invalid("Invalid materialized sandbox mount")); } - if self.class == MountClass::SystemConfig && !self.read_only { + if matches!(self.class, MountClass::SystemConfig | MountClass::Secret) && !self.read_only { return Err(Error::new( ErrorCode::PolicyDenied, - "Materialized system-config mounts must be read-only", + "Materialized system-config and secret mounts must be read-only", + )); + } + if self.class == MountClass::Secret + && (Path::new(&self.logical_name).components().count() != 1 + || self.logical_name.len() > 64) + { + return Err(Error::invalid( + "Materialized secret names must be one bounded path component", )); } Ok(()) diff --git a/crates/platform-services/src/lib.rs b/crates/platform-services/src/lib.rs index b5e7347f9..a96a87919 100644 --- a/crates/platform-services/src/lib.rs +++ b/crates/platform-services/src/lib.rs @@ -81,6 +81,12 @@ pub fn launcher() -> impl SandboxLauncher { pub fn verify_executable(p: &Path, d: &str) -> Result> { verifier().verify(p, d) } + +#[cfg(target_os = "windows")] +pub fn verify_private_data_file(path: &Path, max_bytes: u64) -> Result<()> { + semwright_platform_windows_sys::launch::verify_private_data_file(path, max_bytes) +} + pub fn sandbox_command( s: &semwright_platform_api::launch::SandboxSpec, ) -> Result { diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 9858238ef..1d18752d2 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1,10 +1,9 @@ use crate::{identity::current_user_sid_bytes, job::ProcessJob, pe::require_native_architecture}; use async_trait::async_trait; -#[cfg(test)] -use semwright_platform_api::launch::MountClass; use semwright_platform_api::launch::{ - ExecutableVerifier, MaterializedMount, Mount, SANDBOX_MOUNTS_ENV, SandboxChildControl, - SandboxCpuAccounting, SandboxLauncher, SandboxProcess, SandboxSpec, encode_materialized_mounts, + ExecutableVerifier, MaterializedMount, Mount, MountClass, SANDBOX_MOUNTS_ENV, + SandboxChildControl, SandboxCpuAccounting, SandboxLauncher, SandboxProcess, SandboxSpec, + encode_materialized_mounts, }; use semwright_types::{Error, ErrorCode, Result, unique_id}; use sha2::{Digest, Sha256}; @@ -24,9 +23,9 @@ use std::{ use tokio::{fs::File as TokioFile, process::Command}; use windows::Win32::{ Foundation::{ - CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, GENERIC_ALL, GENERIC_WRITE, HANDLE, - HANDLE_FLAG_INHERIT, HANDLE_FLAGS, HLOCAL, HWND, LocalFree, TRUST_E_EXPLICIT_DISTRUST, - TRUST_E_NOSIGNATURE, WAIT_OBJECT_0, + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, GENERIC_ALL, GENERIC_READ, + GENERIC_WRITE, HANDLE, HANDLE_FLAG_INHERIT, HANDLE_FLAGS, HLOCAL, HWND, LocalFree, + TRUST_E_EXPLICIT_DISTRUST, TRUST_E_NOSIGNATURE, WAIT_OBJECT_0, }, Security::{ ACCESS_ALLOWED_ACE, ACCESS_DENIED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, @@ -58,9 +57,9 @@ use windows::Win32::{ FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_READ_ATTRIBUTES, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, - FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, OPEN_EXISTING, WRITE_DAC, - WRITE_OWNER, + FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, + OPEN_EXISTING, WRITE_DAC, WRITE_OWNER, }, System::{ Com::CoTaskMemFree, @@ -259,7 +258,7 @@ fn sid_matches(sid: PSID, expected: &[u8]) -> bool { unsafe { EqualSid(sid, PSID(expected.as_ptr().cast_mut().cast())).is_ok() } } -fn verify_executable_acl(file: &File) -> Result<()> { +fn verify_trusted_file_acl(file: &File, kind: &str, private_data: bool) -> Result<()> { let mut owner = PSID::default(); let mut dacl: *mut ACL = std::ptr::null_mut(); let mut descriptor = PSECURITY_DESCRIPTOR::default(); @@ -281,14 +280,14 @@ fn verify_executable_acl(file: &File) -> Result<()> { if status.0 != 0 || descriptor.is_invalid() { return Err(Error::new( ErrorCode::PermissionDenied, - "Windows executable security descriptor could not be verified", + format!("Windows {kind} security descriptor could not be verified"), )); } let _descriptor = SecurityDescriptor(descriptor); if owner.is_invalid() || dacl.is_null() { return Err(Error::new( ErrorCode::PermissionDenied, - "Windows executable must have an explicit trusted owner and DACL", + format!("Windows {kind} must have an explicit trusted owner and DACL"), )); } @@ -299,7 +298,7 @@ fn verify_executable_acl(file: &File) -> Result<()> { if !trusted.iter().any(|expected| sid_matches(owner, expected)) { return Err(Error::new( ErrorCode::PermissionDenied, - "Windows executable owner is not the current user, SYSTEM or Administrators", + format!("Windows {kind} owner is not the current user, SYSTEM or Administrators"), )); } @@ -317,17 +316,17 @@ fn verify_executable_acl(file: &File) -> Result<()> { .map_err(|_| { Error::new( ErrorCode::PermissionDenied, - "Windows executable DACL is invalid", + format!("Windows {kind} DACL is invalid"), ) })?; if acl_info.AceCount > 4_096 { return Err(Error::new( ErrorCode::ResourceExhausted, - "Windows executable DACL exceeds verification budget", + format!("Windows {kind} DACL exceeds verification budget"), )); } - let dangerous = FILE_WRITE_DATA.0 + let mutation = FILE_WRITE_DATA.0 | FILE_APPEND_DATA.0 | FILE_WRITE_EA.0 | FILE_WRITE_ATTRIBUTES.0 @@ -337,6 +336,12 @@ fn verify_executable_acl(file: &File) -> Result<()> { | FILE_GENERIC_WRITE.0 | GENERIC_WRITE.0 | GENERIC_ALL.0; + let confidential = FILE_READ_DATA.0 | FILE_GENERIC_READ.0 | GENERIC_READ.0 | GENERIC_ALL.0; + let restricted = if private_data { + mutation | confidential + } else { + mutation + }; for index in 0..acl_info.AceCount { let mut raw: *mut core::ffi::c_void = std::ptr::null_mut(); @@ -345,13 +350,13 @@ fn verify_executable_acl(file: &File) -> Result<()> { unsafe { GetAce(dacl, index, &mut raw) }.map_err(|_| { Error::new( ErrorCode::PermissionDenied, - "Windows executable DACL entry could not be inspected", + format!("Windows {kind} DACL entry could not be inspected"), ) })?; if raw.is_null() { return Err(Error::new( ErrorCode::PermissionDenied, - "Windows executable DACL contains a null ACE", + format!("Windows {kind} DACL contains a null ACE"), )); } // SAFETY: GetAce returned storage owned by the live DACL/security descriptor. @@ -367,26 +372,28 @@ fn verify_executable_acl(file: &File) -> Result<()> { if usize::from(header.AceSize) < std::mem::size_of::() { return Err(Error::new( ErrorCode::PermissionDenied, - "Windows executable DACL contains a malformed allow ACE", + format!("Windows {kind} DACL contains a malformed allow ACE"), )); } // Every allow ACE layout starts with ACE_HEADER followed by the access mask. // SAFETY: the common prefix size was checked above. let ace = unsafe { &*(raw.cast::()) }; - if ace.Mask & dangerous == 0 { + if ace.Mask & restricted == 0 { continue; } if ace_type != ACCESS_ALLOWED_ACE_TYPE { return Err(Error::new( ErrorCode::PermissionDenied, - "Complex Windows executable mutation ACEs are fail-closed", + format!("Complex Windows {kind} restricted ACEs are fail-closed"), )); } let sid = PSID((&ace.SidStart as *const u32).cast_mut().cast()); if !trusted.iter().any(|expected| sid_matches(sid, expected)) { return Err(Error::new( ErrorCode::PermissionDenied, - "Windows executable DACL grants mutation rights to an untrusted principal", + format!( + "Windows {kind} DACL grants restricted rights to an untrusted principal" + ), )); } continue; @@ -399,7 +406,7 @@ fn verify_executable_acl(file: &File) -> Result<()> { _ => { return Err(Error::new( ErrorCode::PermissionDenied, - "Unknown Windows executable DACL ACE type is fail-closed", + format!("Unknown Windows {kind} DACL ACE type is fail-closed"), )); } } @@ -407,6 +414,92 @@ fn verify_executable_acl(file: &File) -> Result<()> { Ok(()) } +pub fn verify_private_data_file(path: &Path, max_bytes: u64) -> Result<()> { + if max_bytes == 0 || max_bytes > 16 * 1024 * 1024 { + return Err(Error::invalid( + "Windows private-file verification budget is invalid", + )); + } + if !path.is_absolute() { + return Err(Error::invalid( + "Windows private data file path must be absolute", + )); + } + let spelling = path.as_os_str().to_string_lossy().to_ascii_lowercase(); + if spelling.starts_with(r"\\") || spelling.starts_with(r"\\?\") || spelling.starts_with(r"\\.\") + { + return Err(Error::new( + ErrorCode::PolicyDenied, + "UNC, extended and device private-data paths are not accepted", + )); + } + + let (validated_identity, is_directory) = match validate_mount_tree(path) { + Ok(validated) => validated, + Err(error) if error.code == ErrorCode::PolicyDenied => { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Unsafe Windows private data source path, type or link count", + )); + } + Err(error) => return Err(error), + }; + if is_directory { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Windows private data source must be a regular file", + )); + } + + let mut options = std::fs::OpenOptions::new(); + options + .read(true) + .share_mode(FILE_SHARE_READ.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); + let mut file = options.open(path)?; + let before = info(&file)?; + if file_identity(&before) != validated_identity { + return Err(Error::new( + ErrorCode::Conflict, + "Windows private data source identity changed during validation", + )); + } + verify_trusted_file_acl(&file, "private data file", true)?; + let size = (u64::from(before.nFileSizeHigh) << 32) | u64::from(before.nFileSizeLow); + if before.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || before.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 + || before.nNumberOfLinks != 1 + || size == 0 + || size > max_bytes + { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Unsafe Windows private data file type, link count or size", + )); + } + + let mut bytes = Vec::with_capacity(size as usize); + file.by_ref().take(max_bytes + 1).read_to_end(&mut bytes)?; + if bytes.is_empty() || bytes.len() as u64 > max_bytes { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Windows private data file exceeds verification budget", + )); + } + let after = info(&file)?; + if !same_identity(&before, &after) + || before.nFileSizeHigh != after.nFileSizeHigh + || before.nFileSizeLow != after.nFileSizeLow + || before.ftLastWriteTime != after.ftLastWriteTime + { + return Err(Error::new( + ErrorCode::Conflict, + "Windows private data file changed while it was being verified", + )); + } + Ok(()) +} + pub struct WindowsVerifier; impl ExecutableVerifier for WindowsVerifier { fn verify(&self, path: &Path, digest: &str) -> Result> { @@ -432,7 +525,7 @@ impl ExecutableVerifier for WindowsVerifier { .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); let mut file = options.open(path)?; let before = info(&file)?; - verify_executable_acl(&file)?; + verify_trusted_file_acl(&file, "executable", false)?; if before.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 || before.nNumberOfLinks != 1 || before.nFileSizeHigh != 0 @@ -1051,6 +1144,9 @@ struct PreparedMountGrant { fn prepare_mount_grant(mount: &Mount) -> Result { mount.validate()?; + if mount.class == MountClass::Secret { + verify_private_data_file(&mount.source, 4096)?; + } if !mount.source.is_absolute() { return Err(Error::invalid( "Windows sandbox mount source must be absolute", diff --git a/crates/platform-windows-sys/tests/private_data.rs b/crates/platform-windows-sys/tests/private_data.rs new file mode 100644 index 000000000..f71772a34 --- /dev/null +++ b/crates/platform-windows-sys/tests/private_data.rs @@ -0,0 +1,57 @@ +#![cfg(target_os = "windows")] + +use semwright_platform_windows_sys::launch::verify_private_data_file; +use semwright_types::ErrorCode; +use std::path::Path; + +fn harden_file(path: &Path) { + let user = std::env::var("USERNAME").expect("Windows USERNAME"); + let principal = match std::env::var("USERDOMAIN") { + Ok(domain) if !domain.is_empty() => format!(r"{domain}\{user}"), + _ => user, + }; + let status = std::process::Command::new("icacls") + .arg(path) + .arg("/inheritance:r") + .arg("/grant:r") + .arg(format!("{principal}:(F)")) + .status() + .expect("run icacls"); + assert!(status.success(), "private-file DACL hardening must succeed"); +} + +#[test] +fn private_data_accepts_small_owner_controlled_file() { + let directory = tempfile::tempdir().expect("private-data directory"); + let secret = directory.path().join("secret.txt"); + std::fs::write(&secret, b"secret").expect("write private data"); + harden_file(&secret); + verify_private_data_file(&secret, 4096).expect("verify private data"); +} + +#[test] +fn private_data_rejects_hard_linked_source() { + let directory = tempfile::tempdir().expect("private-data directory"); + let secret = directory.path().join("secret.txt"); + let alias = directory.path().join("alias.txt"); + std::fs::write(&secret, b"secret").expect("write private data"); + harden_file(&secret); + std::fs::hard_link(&secret, &alias).expect("create hard link"); + let error = verify_private_data_file(&secret, 4096).expect_err("hard-linked private data"); + assert_eq!(error.code, ErrorCode::PermissionDenied); +} + +#[test] +fn private_data_rejects_empty_or_oversized_sources() { + let directory = tempfile::tempdir().expect("private-data directory"); + + let empty = directory.path().join("empty.txt"); + std::fs::write(&empty, b"").expect("write empty private data"); + harden_file(&empty); + assert!(verify_private_data_file(&empty, 4096).is_err()); + + let oversized = directory.path().join("oversized.txt"); + std::fs::write(&oversized, vec![b'x'; 4097]).expect("write oversized private data"); + harden_file(&oversized); + assert!(verify_private_data_file(&oversized, 4096).is_err()); +} diff --git a/scripts/dev/driver-conformance.sh b/scripts/dev/driver-conformance.sh index 0426498f6..2ffce7194 100755 --- a/scripts/dev/driver-conformance.sh +++ b/scripts/dev/driver-conformance.sh @@ -66,7 +66,7 @@ assert inspect["identity"]["namespace"] == "driver.fixture." assert inspect["policy_grants_changed"] is False assert conformance["provider"] == "driver:fixture" assert conformance["namespace"] == "driver.fixture." -assert conformance["capabilities"] == 4 # ping + mount_probe + long + continuity disconnect fixture +assert conformance["capabilities"] == 6 # ping + mount_probe + config_probe + secret_probe + long + continuity disconnect fixture assert conformance["sandboxed"] is True assert conformance["persistent_process"] is True assert conformance["health"] is True