diff --git a/Cargo.lock b/Cargo.lock index 109e6df8d5..d8e5d933a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4394,6 +4394,8 @@ dependencies = [ "openshell-core", "rustix 1.1.4", "serde", + "serde_json", + "sha2 0.10.9", "tokio", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 74bbfb4aea..bf1b920ee7 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -63,20 +63,52 @@ replacement from granting authority. ## Startup Flow 1. The driver resolves the immutable workload identity, installs the outer - network fence, and starts `openshell-sandbox` with one-use bootstrap state. + network fence, validates its native evidence, and starts `openshell-sandbox` + with one-use bootstrap state. Docker inspects container networking, + Kubernetes verifies its NetworkPolicy, and VM drivers inspect the guest + device model; those native schemas remain in their driver crates. 2. The sandbox consumes and unlinks bootstrap material, proves the admitted runtime posture, and listens on the protected driver channel. It does not run untrusted code yet. 3. `openshell-supervisor` loads policy and runtime settings from the gateway, attaches to the sandbox, and verifies the driver's generation and evidence. 4. The sandbox installs its seccomp notification broker and Landlock baseline, - then reports measured confirmation. The supervisor must accept that evidence -before it sends the launch permit. + validates its mechanism-specific audit evidence, and reports backend-neutral + enforcement properties. The supervisor must accept those properties and + their immutable session and resource binding before it sends the launch + permit. Other isolation backends may establish the same properties with + different mechanisms and retain their detailed evidence in backend-owned + audit data. 5. The sandbox starts the canonical process through its single workload launcher. The supervisor starts SSH and registers its gateway session. 6. Exec, signaling, PTY, DNS, TCP, and loopback-forwarding operations cross the authenticated channel for the lifetime of the sandbox generation. +The shared isolation contract receives only normalized outer-fence guarantees: +egress is default-deny, there is no unmanaged egress path, the evidence is bound +to the sandbox generation, revocation has been verified, and controller loss +fails closed. A digest commits those guarantees to the native +evidence without teaching the shared contract about container networks, +Kubernetes objects, VM devices, or accelerator resources. + +The component that owns the outer fence also validates its native evidence and +makes that projection explicitly. In the current Docker, Podman, Kubernetes, +and VM placements, that component is the compute driver. A delegated isolation +backend may own the fence and make the same projection instead. Non-empty native +evidence alone does not establish a guarantee: + +| Current enforcement owner | Native evidence | Guarantees projected by the owner | +|---|---|---| +| Docker | Pinned container ID, `network_mode=none`, and no unexpected network attachments | No workload route establishes default-deny, revocation, and controller-loss behavior; the attachment inspection establishes that no unmanaged route exists. | +| Podman | Pinned container ID, `--network=none`, and no unexpected network attachments | The same container-network facts establish the same four guarantees. | +| Kubernetes | NetworkPolicy UID and resource version, ingress and egress isolation, and zero workload egress rules | The persisted, selecting policy establishes default-deny and continued denial after revocation or controller loss; zero egress rules establish that no unmanaged route is permitted. | +| VM | Generation and zero guest network devices | The absent NIC establishes all four guarantees; approved traffic uses the separate supervisor-owned channel. | + +The shared contract checks that all four guarantees are present, that the +projection names the admitted generation, and that its evidence digest matches +the value passed to the workload-side runtime. It does not infer guarantees or +interpret the native fields. + When the admitted main process exits, its status and retained terminal output remain available. The confirmed sandbox and supervisor-owned access plane continue to serve policy-authorized exec and loopback forwarding until explicit stop or @@ -100,7 +132,7 @@ OpenShell uses overlapping controls rather than a single sandbox primitive: | Filesystem policy | Landlock restricts the paths the agent can read or write. | | Process policy | Sandbox and children run as one immutable non-root identity with zero capabilities. | | Seccomp notification | Virtualizes supported INET sockets and sends DNS/TCP decisions to the supervisor without nftables or proxy environment variables. | -| Driver outer fence | Docker `network_mode=none`, a NIC-less VM, or Kubernetes NetworkPolicy prevents any missed or unsupported kernel path from escaping. | +| Outer network fence | The component that owns network enforcement prevents any missed or unsupported kernel path from escaping. Current examples are Docker `network_mode=none`, a NIC-less VM, and Kubernetes NetworkPolicy. | | Policy proxy | Evaluates destination, binary identity, TLS/L7 rules, SSRF checks, and inference interception. | The supervisor may enrich baseline filesystem allowances for runtime-required @@ -201,7 +233,7 @@ cannot transfer that approval to another socket. The outer fence remains mandatory. If notification handling misses a syscall, loses the supervisor, exceeds a bound, or encounters an unsupported socket -type, the request fails and the driver-owned fence still blocks direct egress. +type, the request fails and the outer fence still blocks direct egress. CONNECT and absolute-form forward HTTP are explicit-proxy adapters over the same egress pipeline. Each adapter normalizes its request into an egress intent, and diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 5e50a37ad7..d921b8c348 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -11,12 +11,52 @@ use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::PathBuf; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct DockerOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl DockerOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() { + return Err(BackendError::Descriptor( + "Docker outer fence evidence is incomplete".to_string(), + )); + } + let mut established = Vec::new(); + if self.network_mode == "none" { + // With no container network namespace attachment, workload egress + // remains denied both after revocation and if the supervisor exits. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.unexpected_networks.is_empty() { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Docker outer fence evidence: {error}")) + })?; + let projection = + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; + projection.validate(generation)?; + Ok(projection) + } +} /// Driver-owned inputs that bind one Docker container to one boundary. pub struct DockerBoundarySpec { @@ -48,8 +88,7 @@ pub struct DockerBoundaryProvisioning { impl DockerBoundarySpec { /// Produce both sides of the common protocol from the same immutable /// Docker coordinates so attach cannot bind a different container. - #[must_use] - pub fn provision(self) -> DockerBoundaryProvisioning { + pub fn provision(self) -> Result { let mut resource_claims = BTreeMap::from([ ("docker.container_id".to_string(), self.container_id), ("docker.image_identity".to_string(), self.image_identity), @@ -57,12 +96,14 @@ impl DockerBoundarySpec { if self.gpu_requested { resource_claims.insert(GPU_RESOURCE_CLAIM.to_string(), "true".to_string()); } - let driver_fence = DriverFenceEvidence::Docker { - container_id: resource_claims["docker.container_id"].clone(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - DockerBoundaryProvisioning { + let unexpected_networks = Vec::new(); + let outer_fence = DockerOuterFenceEvidence { + container_id: &resource_claims["docker.container_id"], + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&self.generation)?; + Ok(DockerBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -78,7 +119,7 @@ impl DockerBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,9 +133,9 @@ impl DockerBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -102,6 +143,30 @@ impl DockerBoundarySpec { mod tests { use super::*; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["bridge".to_string()]; + for evidence in [ + DockerOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + DockerOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + #[test] fn provisioning_binds_container_and_image_claims() { let session_id = openshell_core::SandboxSessionId::new(); @@ -143,7 +208,8 @@ mod tests { .unwrap(), child_env: HashMap::new(), } - .provision(); + .provision() + .unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -158,14 +224,14 @@ mod tests { "true" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 4230f75136..461ec6079e 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -4433,7 +4433,8 @@ async fn prepare_docker_boundary_files( workload_identity: workload_identity.clone(), child_env: docker_child_environment(sandbox), } - .provision(); + .provision() + .map_err(|error| Status::failed_precondition(error.to_string()))?; let boundary_config = provisioning .boundary_config .encode() diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index cbdd5bcc7f..1813496660 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -2374,7 +2374,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() @@ -2612,7 +2613,8 @@ impl KubernetesComputeDriver { workload_identity, child_env, } - .provision(); + .provision() + .map_err(|error| KubernetesDriverError::Message(error.to_string()))?; let descriptor = provisioned .runtime_descriptor .backend_descriptor() diff --git a/crates/openshell-driver-kubernetes/src/isolation.rs b/crates/openshell-driver-kubernetes/src/isolation.rs index bb9ae8ff66..6a94e6abaa 100644 --- a/crates/openshell-driver-kubernetes/src/isolation.rs +++ b/crates/openshell-driver-kubernetes/src/isolation.rs @@ -20,11 +20,53 @@ use k8s_openapi::api::networking::v1::{ use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; use kube::core::ObjectMeta; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; + +#[derive(Serialize)] +struct KubernetesOuterFenceEvidence<'a> { + network_policy_uid: &'a str, + network_policy_resource_version: &'a str, + ingress_isolated: bool, + egress_isolated: bool, + egress_rule_count: u32, +} + +impl KubernetesOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.network_policy_uid.is_empty() || self.network_policy_resource_version.is_empty() { + return Err(BackendError::Descriptor( + "Kubernetes outer fence evidence is incomplete".to_string(), + )); + } + let mut established = Vec::new(); + if self.ingress_isolated && self.egress_isolated && self.egress_rule_count == 0 { + // A persisted policy selecting both directions with no egress rule + // continues to deny direct egress after revocation or controller loss. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.egress_isolated && self.egress_rule_count == 0 { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode Kubernetes outer fence evidence: {error}")) + })?; + let projection = + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded)?; + projection.validate(generation)?; + Ok(projection) + } +} /// Isolation backend implemented by the `OpenShell` sandbox runtime. pub const BACKEND_NAME: &str = openshell_sandbox_backend::BACKEND_NAME; @@ -181,8 +223,7 @@ pub struct KubernetesSandboxRuntimeBoundaryProvisioning { impl KubernetesSandboxRuntimeBoundarySpec { /// Produce both sides of the common protocol from one observed Kubernetes /// resource set so a stale or recreated object cannot be attached. - #[must_use] - pub fn provision(self) -> KubernetesSandboxRuntimeBoundaryProvisioning { + pub fn provision(self) -> Result { let resource_claims = BTreeMap::from([ ("kubernetes.namespace_uid".to_string(), self.namespace_uid), ( @@ -206,15 +247,16 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.egress_policy_resource_version, ), ]); - let driver_fence = DriverFenceEvidence::Kubernetes { - network_policy_uid: resource_claims["kubernetes.egress_policy_uid"].clone(), - network_policy_resource_version: - resource_claims["kubernetes.egress_policy_resource_version"].clone(), + let outer_fence = KubernetesOuterFenceEvidence { + network_policy_uid: &resource_claims["kubernetes.egress_policy_uid"], + network_policy_resource_version: &resource_claims + ["kubernetes.egress_policy_resource_version"], ingress_isolated: true, egress_isolated: true, egress_rule_count: 0, - }; - KubernetesSandboxRuntimeBoundaryProvisioning { + } + .project(&self.generation)?; + Ok(KubernetesSandboxRuntimeBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), generation: self.generation.clone(), @@ -233,7 +275,7 @@ impl KubernetesSandboxRuntimeBoundarySpec { self.workload_pod_uid_path, )]), workload_identity: self.workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -248,9 +290,9 @@ impl KubernetesSandboxRuntimeBoundarySpec { tls: self.supervisor_tls, host_gateway_ip: self.host_gateway_ip, resource_claims, - driver_fence, + outer_fence, }, - } + }) } } @@ -258,6 +300,49 @@ impl KubernetesSandboxRuntimeBoundarySpec { mod tests { use super::*; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + for evidence in [ + KubernetesOuterFenceEvidence { + network_policy_uid: "", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: false, + egress_isolated: true, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: false, + egress_rule_count: 0, + }, + KubernetesOuterFenceEvidence { + network_policy_uid: "uid", + network_policy_resource_version: "1", + ingress_isolated: true, + egress_isolated: true, + egress_rule_count: 1, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + fn spec() -> KubernetesSandboxRuntimeBoundarySpec { KubernetesSandboxRuntimeBoundarySpec { boundary_id: "sandbox-1".to_string(), @@ -303,7 +388,7 @@ mod tests { #[test] fn provisioning_binds_identical_kubernetes_resource_claims() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.resource_claims, @@ -318,21 +403,21 @@ mod tests { "1945" ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } #[test] fn provisioning_uses_one_shared_tcp_protocol_across_pods() { - let provisioned = spec().provision(); + let provisioned = spec().provision().unwrap(); assert_eq!( provisioned.boundary_config.listener, diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index b616fa6474..69679661f9 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -10,7 +10,9 @@ use std::path::PathBuf; use openshell_core::ComputeDriverError; use openshell_core::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{DriverFenceEvidence, ResolvedWorkloadIdentity}; +use openshell_isolation_interface::contract::{ + OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, +}; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, @@ -27,6 +29,40 @@ pub const AUTH_BUNDLE_PATH: &str = "/.openshell/supervisor/auth.json"; pub const RESTART_METADATA_PATH: &str = "/.openshell/supervisor/restart-metadata.json"; const SOCKET_PATH: &str = "/.openshell/channel/sandbox/control.sock"; +#[derive(Serialize)] +struct PodmanOuterFenceEvidence<'a> { + container_id: &'a str, + network_mode: &'static str, + unexpected_networks: &'a [String], +} + +impl PodmanOuterFenceEvidence<'_> { + fn project(&self, generation: &str) -> Result { + if self.container_id.is_empty() { + return Err(invalid("Podman outer fence evidence is incomplete")); + } + let mut established = Vec::new(); + if self.network_mode == "none" { + // With no container network namespace attachment, workload egress + // remains denied both after revocation and if the supervisor exits. + established.extend([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + } + if self.unexpected_networks.is_empty() { + established.push(OuterFenceGuarantee::NoUnmanagedEgressPath); + } + let encoded = serde_json::to_vec(self).map_err(invalid)?; + let projection = + OuterFenceGuarantees::from_enforcement_evidence(generation, established, &encoded) + .map_err(invalid)?; + projection.validate(generation).map_err(invalid)?; + Ok(projection) + } +} + pub fn supervisor_name(id: &str) -> String { format!("openshell-supervisor-{id}") } @@ -159,15 +195,17 @@ pub fn bootstrap_archives( identity.resource_digest.clone(), ), ]); - let driver_fence = DriverFenceEvidence::Podman { - container_id: container_id.into(), - network_mode: "none".into(), - unexpected_networks: Vec::new(), - }; let runtime_generation = launch_authentication .supervisor .runtime_generation .to_string(); + let unexpected_networks = Vec::new(); + let outer_fence = PodmanOuterFenceEvidence { + container_id, + network_mode: "none", + unexpected_networks: &unexpected_networks, + } + .project(&runtime_generation)?; let verification_keys = launch_authentication .verification_keys .iter() @@ -198,7 +236,7 @@ pub fn bootstrap_archives( resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: child_env.clone(), }; let runtime_descriptor = SandboxRuntimeDescriptor { @@ -215,7 +253,7 @@ pub fn bootstrap_archives( host_gateway_ip: None, resource_claims, workload_identity: identity.clone(), - driver_fence, + outer_fence, }; // Libpod resolves the requested upload destination once for a stopped // container. Archive entries must be relative to the selected named volume, @@ -327,6 +365,30 @@ mod tests { SupervisorAuthBundle, }; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + let unexpected_networks = vec!["podman".to_string()]; + for evidence in [ + PodmanOuterFenceEvidence { + container_id: "", + network_mode: "none", + unexpected_networks: &[], + }, + PodmanOuterFenceEvidence { + container_id: "container", + network_mode: "bridge", + unexpected_networks: &[], + }, + PodmanOuterFenceEvidence { + container_id: "container", + network_mode: "none", + unexpected_networks: &unexpected_networks, + }, + ] { + assert!(evidence.project("generation-1").is_err()); + } + } + fn authentication() -> SandboxLaunchAuthentication { SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { @@ -440,9 +502,12 @@ mod tests { .unwrap(); assert_eq!(config.boundary_id, runtime_descriptor.boundary_id); assert_eq!(config.session_id, runtime_descriptor.session_id); - assert_eq!(config.driver_fence, runtime_descriptor.driver_fence); + assert_eq!(config.outer_fence, runtime_descriptor.outer_fence); assert_eq!(config.workload_identity, identity); - runtime_descriptor.driver_fence.validate().unwrap(); + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation) + .unwrap(); let restart_metadata: RestartMetadata = serde_json::from_slice( supervisor .get(&PathBuf::from( diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 49c1135310..841323ab23 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -9,14 +9,50 @@ //! the common control and boundary behavior. use openshell_isolation_interface::contract::{ - BackendError, DriverFenceEvidence, ResolvedWorkloadIdentity, + BackendError, OuterFenceGuarantee, OuterFenceGuarantees, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::boundary_protocol::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; +use serde::Serialize; use std::collections::{BTreeMap, HashMap}; +#[derive(Serialize)] +struct VmOuterFenceEvidence<'a> { + generation: &'a str, + network_device_count: u32, +} + +impl VmOuterFenceEvidence<'_> { + fn project(&self) -> Result { + if self.generation.is_empty() { + return Err(BackendError::Descriptor( + "VM outer fence evidence is incomplete".to_string(), + )); + } + let established = (self.network_device_count == 0).then_some([ + // A guest with no NIC has no kernel network path. Closing the + // supervisor-owned channel revokes access, and controller loss + // cannot introduce a device. + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let encoded = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode VM outer fence evidence: {error}")) + })?; + let projection = OuterFenceGuarantees::from_enforcement_evidence( + self.generation, + established.into_iter().flatten(), + &encoded, + )?; + projection.validate(self.generation)?; + Ok(projection) + } +} + /// Driver-owned inputs that bind one VM generation to one supervisor boundary. pub struct VmBoundarySpec { pub boundary_id: String, @@ -57,10 +93,11 @@ impl VmBoundarySpec { ("vm.generation".to_string(), self.generation.clone()), ("vm.image_identity".to_string(), self.image_identity), ]); - let driver_fence = DriverFenceEvidence::Vm { - generation: self.generation.clone(), + let outer_fence = VmOuterFenceEvidence { + generation: &self.generation, network_device_count: 0, - }; + } + .project()?; Ok(VmBoundaryProvisioning { boundary_config: BoundaryConfig { boundary_id: self.boundary_id.clone(), @@ -77,7 +114,7 @@ impl VmBoundarySpec { resource_claims: resource_claims.clone(), resource_claim_files: BTreeMap::new(), workload_identity: workload_identity.clone(), - driver_fence: driver_fence.clone(), + outer_fence: outer_fence.clone(), child_env: self.child_env, }, runtime_descriptor: SandboxRuntimeDescriptor { @@ -92,7 +129,7 @@ impl VmBoundarySpec { // after crossing the authenticated boundary channel. host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims, - driver_fence, + outer_fence, }, }) } @@ -106,6 +143,26 @@ mod tests { generate_sandbox_tls_material, }; + #[test] + fn outer_fence_projection_rejects_each_missing_native_fact() { + assert!( + VmOuterFenceEvidence { + generation: "", + network_device_count: 0, + } + .project() + .is_err() + ); + assert!( + VmOuterFenceEvidence { + generation: "generation-1", + network_device_count: 1, + } + .project() + .is_err() + ); + } + #[test] fn provisioning_binds_identical_resource_claims() { let session_id = openshell_core::SandboxSessionId::new(); @@ -155,14 +212,14 @@ mod tests { Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) ); assert_eq!( - provisioned.boundary_config.driver_fence, - provisioned.runtime_descriptor.driver_fence + provisioned.boundary_config.outer_fence, + provisioned.runtime_descriptor.outer_fence ); assert!( provisioned .runtime_descriptor - .driver_fence - .validate() + .outer_fence + .validate("generation-1") .is_ok() ); } diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 94220dda73..0426a9011f 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -14,6 +14,8 @@ repository.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } tokio = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8e68d48c77..163cb9100f 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -16,7 +16,8 @@ //! //! Each transition consumes the prior state by value (`self: Box`). //! Trusted backend implementations construct confirmation through a validating -//! constructor; the supervisor cannot obtain a ready boundary without evidence. +//! constructor; the supervisor cannot obtain a ready boundary without confirmed +//! backend-neutral enforcement properties. //! The supervisor holds no `match`/downcast on concrete backends: the //! registry is the only lookup by `backend_name`, and everything past it is a //! `Box` / `Arc`. @@ -30,7 +31,7 @@ //! The contract is transport-neutral. Compute drivers keep runtime placement //! and coordination details behind these interfaces. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -385,190 +386,165 @@ pub trait BoundBoundary: Send { async fn confirm(self: Box) -> Result; } -/// Capability masks measured from `/proc//status`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityEvidence { - pub inheritable: u64, - pub permitted: u64, - pub effective: u64, - pub bounding: u64, - pub ambient: u64, +/// Backend-neutral guarantees established by the component that owns the outer +/// network fence. +/// +/// The enforcement owner may be a compute driver or a delegated isolation +/// backend. It owns its native evidence schema and the code that validates it. +/// After validation, it projects that evidence into these guarantees and +/// supplies a digest that binds the original evidence to this generation. The +/// common runtime only validates and compares this projection; it never +/// interprets backend- or runtime-specific fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OuterFenceGuarantee { + /// No workload packet can leave without an explicit mediated decision. + DefaultDenyEgress, + /// The enforcement owner found no network path outside the mediated boundary. + NoUnmanagedEgressPath, + /// Previously granted access can be revoked by the enforcement owner. + RevocationVerified, + /// Loss of the fence's controller does not open network access. + ControllerLossFailsClosed, } -impl CapabilityEvidence { - /// True only when every Linux capability set is empty. - #[must_use] - pub const fn is_empty(self) -> bool { - self.inheritable == 0 - && self.permitted == 0 - && self.effective == 0 - && self.bounding == 0 - && self.ambient == 0 - } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OuterFenceGuarantees { + /// Sandbox generation for which the evidence was collected. + pub generation: String, + /// Complete set of normalized guarantees established by the enforcement owner. + pub established: BTreeSet, + /// Commitment to the enforcement owner's native evidence. + pub evidence_digest: Sha256Digest, } -/// Active seccomp notification and socket-broker evidence. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "each independently measured kernel operation is reported explicitly" -)] -pub struct SeccompEvidence { - pub new_listener: bool, - pub notification_round_trip: bool, - pub id_validation: bool, - pub addfd_send: bool, - pub retained_socket_operation: bool, - pub proc_fd_identity: bool, - pub task_memory_read: bool, - pub task_memory_write: bool, - pub cancellation: bool, +impl OuterFenceGuarantees { + /// Bind guarantees explicitly established by validated enforcement evidence. + /// + /// This constructor deliberately does not infer guarantees from the mere + /// presence of evidence. The enforcement owner must inspect its native + /// state and project each established guarantee before calling this + /// function. + pub fn from_enforcement_evidence( + generation: impl Into, + established: impl IntoIterator, + native_evidence: &[u8], + ) -> Result { + let generation = generation.into(); + if generation.is_empty() || native_evidence.is_empty() { + return Err(BackendError::Descriptor( + "outer fence generation and native evidence are required".to_string(), + )); + } + let mut binding = Vec::with_capacity(8 + generation.len() + native_evidence.len()); + binding.extend_from_slice(&(generation.len() as u64).to_be_bytes()); + binding.extend_from_slice(generation.as_bytes()); + binding.extend_from_slice(native_evidence); + Ok(Self { + generation, + established: established.into_iter().collect(), + evidence_digest: Sha256Digest::compute(&binding), + }) + } + + /// Validate the common guarantees against the admitted generation. + pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { + let required = BTreeSet::from([ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ]); + let complete = !self.generation.is_empty() + && self.generation == expected_generation + && self.established == required; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "outer fence guarantees are incomplete or bound to another generation".to_string(), + )) + } + } } -/// Driver-owned evidence that the mandatory outer network fence is installed. +/// A backend-neutral security property established before agent launch. /// -/// The sandbox cannot observe the Docker daemon, Kubernetes API, or VM device -/// model directly. Drivers therefore bind the exact fence they validated into -/// both protected bootstrap halves. The sandbox reports that value back during -/// confirmation, and the supervisor rejects any mismatch before agent launch. +/// `mechanism` is diagnostic and audit metadata. It never authorizes launch; +/// the registered backend is responsible for validating its mechanism-specific +/// evidence before setting `enforced`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "backend", rename_all = "kebab-case", deny_unknown_fields)] -pub enum DriverFenceEvidence { - Docker { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Podman { - container_id: String, - network_mode: String, - unexpected_networks: Vec, - }, - Kubernetes { - network_policy_uid: String, - network_policy_resource_version: String, - ingress_isolated: bool, - egress_isolated: bool, - egress_rule_count: u32, - }, - Vm { - generation: String, - network_device_count: u32, - }, +pub struct EnforcedProperty { + pub enforced: bool, + pub mechanism: String, } -impl DriverFenceEvidence { +impl EnforcedProperty { #[must_use] - pub const fn driver_name(&self) -> &'static str { - match self { - Self::Docker { .. } => "docker", - Self::Podman { .. } => "podman", - Self::Kubernetes { .. } => "kubernetes", - Self::Vm { .. } => "vm", + pub fn new(enforced: bool, mechanism: impl Into) -> Self { + Self { + enforced, + mechanism: mechanism.into(), } } - /// Validate the concrete outer-fence properties reported by the compute driver. - pub fn validate(&self) -> Result<(), BackendError> { - let valid = match self { - Self::Docker { - container_id, - network_mode, - unexpected_networks, - } - | Self::Podman { - container_id, - network_mode, - unexpected_networks, - } => { - !container_id.is_empty() && network_mode == "none" && unexpected_networks.is_empty() - } - Self::Kubernetes { - network_policy_uid, - network_policy_resource_version, - ingress_isolated, - egress_isolated, - egress_rule_count, - } => { - !network_policy_uid.is_empty() - && !network_policy_resource_version.is_empty() - && *ingress_isolated - && *egress_isolated - && *egress_rule_count == 0 - } - Self::Vm { - generation, - network_device_count, - } => !generation.is_empty() && *network_device_count == 0, - }; - if valid { + fn validate(&self, name: &str) -> Result<(), BackendError> { + if self.enforced && !self.mechanism.trim().is_empty() { Ok(()) } else { Err(BackendError::Confirm(format!( - "{} driver fence evidence is incomplete", - self.driver_name() + "{name} is not enforced or has no declared mechanism" ))) } } } -/// Measured sandbox-owned evidence produced before agent launch. +/// Security properties every isolation backend establishes before launch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundaryProperties { + pub filesystem_confinement: EnforcedProperty, + pub egress_interception: EnforcedProperty, + pub request_attribution: EnforcedProperty, + pub privilege_floor: EnforcedProperty, +} + +impl BoundaryProperties { + fn validate(&self) -> Result<(), BackendError> { + self.filesystem_confinement + .validate("filesystem confinement")?; + self.egress_interception.validate("egress interception")?; + self.request_attribution.validate("request attribution")?; + self.privilege_floor.validate("privilege floor") + } +} + +/// Per-boundary confirmation produced before agent launch. +/// +/// Common validation binds the confirmation to the admitted workload and +/// checks backend-neutral properties. `backend_audit` remains opaque to this +/// crate; the registered backend owns its schema and validates it before +/// constructing [`ConfirmedBoundary`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "confirmation preserves independently measured security results" -)] -pub struct SandboxConfirmEvidence { +pub struct BoundaryConfirmation { pub generation: String, pub identity: ResolvedWorkloadIdentity, - pub capabilities: CapabilityEvidence, - pub no_new_privileges: bool, - pub sandbox_dumpable: bool, - pub child_dumpable: bool, - pub core_limit_zero: bool, - pub native_architecture: String, - pub kernel_release: String, - pub seccomp: SeccompEvidence, - pub landlock_abi: u32, - pub landlock_allow_deny: bool, - pub udp_dns_round_trip: bool, - pub tcp_dns_round_trip: bool, - pub tcp_allow_round_trip: bool, - pub tcp_deny_round_trip: bool, + pub properties: BoundaryProperties, pub authenticated_supervisor: bool, pub session_id: SandboxSessionId, - pub driver_fence: DriverFenceEvidence, - /// The driver-owned containment primitive terminates the workload when its + pub outer_fence: OuterFenceGuarantees, + /// The backend-owned containment primitive terminates the workload when its /// Sandbox Runtime exits. pub runtime_exit_terminates_workload: bool, pub resource_claims: BTreeMap, + pub backend_audit: serde_json::Value, } -impl SandboxConfirmEvidence { - /// Validate the security-critical evidence required before launch. +impl BoundaryConfirmation { + /// Validate common security properties and immutable launch binding. pub fn validate(&self, expected: &ResolvedWorkloadIdentity) -> Result<(), BackendError> { - self.driver_fence.validate()?; + self.outer_fence.validate(&self.generation)?; + self.properties.validate()?; let complete = &self.identity == expected - && self.capabilities.is_empty() - && self.no_new_privileges - && !self.sandbox_dumpable - && self.child_dumpable - && self.core_limit_zero - && self.seccomp.new_listener - && self.seccomp.notification_round_trip - && self.seccomp.id_validation - && self.seccomp.addfd_send - && self.seccomp.retained_socket_operation - && self.seccomp.proc_fd_identity - && self.seccomp.task_memory_read - && self.seccomp.task_memory_write - && self.seccomp.cancellation - && self.landlock_abi >= 3 - && self.landlock_allow_deny - && self.udp_dns_round_trip - && self.tcp_dns_round_trip - && self.tcp_allow_round_trip - && self.tcp_deny_round_trip && self.authenticated_supervisor && self.runtime_exit_terminates_workload && !self.generation.is_empty(); @@ -576,41 +552,45 @@ impl SandboxConfirmEvidence { Ok(()) } else { Err(BackendError::Confirm( - "sandbox confirmation evidence is incomplete or mismatched".to_string(), + "boundary confirmation is incomplete or mismatched".to_string(), )) } } } -/// Ready boundary paired with the evidence measured by `confirm`. +/// Ready boundary paired with the confirmation established by `confirm`. pub struct ConfirmedBoundary { boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, } impl ConfirmedBoundary { - /// Construct confirmation after checking measured evidence against the - /// immutable identity admitted at attach time. + /// Construct confirmation after checking backend-neutral properties and + /// immutable identity binding. /// - /// Backend implementations are trusted to collect this evidence and bind - /// it to their resource. This constructor enforces the common requirements - /// without requiring those implementations to live in the interface crate. + /// Backend implementations are trusted to validate their audit evidence and + /// bind this confirmation to their resource. This constructor enforces the + /// common requirements without requiring those implementations to live in + /// the interface crate. /// /// # Errors /// - /// Returns an error if evidence is incomplete or the identity does not match. + /// Returns an error if confirmation is incomplete or the identity does not match. pub fn try_new( boundary: Box, - evidence: SandboxConfirmEvidence, + confirmation: BoundaryConfirmation, expected: &ResolvedWorkloadIdentity, ) -> Result { - evidence.validate(expected)?; - Ok(Self { boundary, evidence }) + confirmation.validate(expected)?; + Ok(Self { + boundary, + confirmation, + }) } - /// Return the measured evidence carried by this confirmed state. - pub fn evidence(&self) -> &SandboxConfirmEvidence { - &self.evidence + /// Return the record carried by this confirmed state. + pub fn confirmation(&self) -> &BoundaryConfirmation { + &self.confirmation } /// Consume confirmation and advance to the sole launch-capable state. @@ -889,6 +869,12 @@ impl From for String { } impl Sha256Digest { + fn compute(bytes: &[u8]) -> Self { + use sha2::{Digest as _, Sha256}; + + Self(Sha256::digest(bytes).into()) + } + /// Return the raw digest bytes. #[must_use] pub fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index a7740d8a7d..b09320dc97 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -21,8 +21,10 @@ //! `start_agent` -> Running. Nothing untrusted runs inside the boundary until it //! is confirmed ready. This is enforced *by construction*: each transition //! consumes the prior state by value. Trusted backends construct confirmation -//! through [`contract::ConfirmedBoundary::try_new`], which checks common evidence -//! before the supervisor can obtain a [`contract::ReadyBoundary`]. +//! through [`contract::ConfirmedBoundary::try_new`], which checks common +//! enforcement properties and immutable launch binding before the supervisor +//! can obtain a [`contract::ReadyBoundary`]. Mechanism-specific evidence stays +//! owned by the backend that can interpret it. //! //! [`AgentSpec`] is shared between the workload definition the supervisor //! submits and the [`contract::SandboxContext`] that `attach` binds to a diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..37abe4cd73 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -239,7 +239,7 @@ impl BoundBoundary for MockBound { async fn confirm(self: Box) -> Result { ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &workload_identity(), ) } @@ -368,80 +368,73 @@ fn workload_identity() -> ResolvedWorkloadIdentity { .unwrap() } -fn confirmation_evidence() -> SandboxConfirmEvidence { - SandboxConfirmEvidence { +fn complete_outer_fence(generation: &str, evidence: &[u8]) -> OuterFenceGuarantees { + OuterFenceGuarantees::from_enforcement_evidence( + generation, + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + evidence, + ) + .unwrap() +} + +fn confirmation() -> BoundaryConfirmation { + BoundaryConfirmation { generation: "generation-1".to_string(), identity: workload_identity(), - capabilities: CapabilityEvidence { - inheritable: 0, - permitted: 0, - effective: 0, - bounding: 0, - ambient: 0, - }, - no_new_privileges: true, - sandbox_dumpable: false, - child_dumpable: true, - core_limit_zero: true, - native_architecture: std::env::consts::ARCH.to_string(), - kernel_release: "test".to_string(), - seccomp: SeccompEvidence { - new_listener: true, - notification_round_trip: true, - id_validation: true, - addfd_send: true, - retained_socket_operation: true, - proc_fd_identity: true, - task_memory_read: true, - task_memory_write: true, - cancellation: true, + properties: BoundaryProperties { + filesystem_confinement: EnforcedProperty::new(true, "mock-filesystem"), + egress_interception: EnforcedProperty::new(true, "mock-egress"), + request_attribution: EnforcedProperty::new(true, "mock-attribution"), + privilege_floor: EnforcedProperty::new(true, "mock-privilege-floor"), }, - landlock_abi: 3, - landlock_allow_deny: true, - udp_dns_round_trip: true, - tcp_dns_round_trip: true, - tcp_allow_round_trip: true, - tcp_deny_round_trip: true, authenticated_supervisor: true, session_id: SandboxSessionId::new(), - driver_fence: DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }, + outer_fence: complete_outer_fence("generation-1", b"mock-fence-evidence"), runtime_exit_terminates_workload: true, resource_claims: BTreeMap::new(), + backend_audit: serde_json::json!({"backend": "mock"}), } } #[test] -fn driver_fence_evidence_is_backend_specific_and_fail_closed() { - let docker = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "none".to_string(), - unexpected_networks: Vec::new(), - }; - let kubernetes = DriverFenceEvidence::Kubernetes { - network_policy_uid: "policy-uid".to_string(), - network_policy_resource_version: "42".to_string(), - ingress_isolated: true, - egress_isolated: true, - egress_rule_count: 0, - }; - let vm = DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - }; +fn outer_fence_guarantees_are_backend_neutral_and_fail_closed() { + let fence = complete_outer_fence("generation-1", b"native-driver-evidence"); + assert!(fence.validate("generation-1").is_ok()); + assert_ne!( + fence.evidence_digest, + complete_outer_fence("generation-2", b"native-driver-evidence").evidence_digest + ); - assert!(docker.validate().is_ok()); - assert!(kubernetes.validate().is_ok()); - assert!(vm.validate().is_ok()); + let mut wrong_generation = fence.clone(); + wrong_generation.generation = "generation-2".to_string(); + assert!(wrong_generation.validate("generation-1").is_err()); - let drifted = DriverFenceEvidence::Docker { - container_id: "sha256:container".to_string(), - network_mode: "bridge".to_string(), - unexpected_networks: vec!["bridge".to_string()], - }; - assert!(drifted.validate().is_err()); + for guarantee in [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ] { + let mut incomplete = fence.clone(); + incomplete.established.remove(&guarantee); + assert!(incomplete.validate("generation-1").is_err()); + } + + assert!(OuterFenceGuarantees::from_enforcement_evidence("", [], b"evidence").is_err()); + assert!(OuterFenceGuarantees::from_enforcement_evidence("generation-1", [], b"").is_err()); + + let unproven = OuterFenceGuarantees::from_enforcement_evidence( + "generation-1", + [OuterFenceGuarantee::DefaultDenyEgress], + b"native-driver-evidence", + ) + .unwrap(); + assert!(unproven.validate("generation-1").is_err()); } /// The backend-independent supervisor sequence. Identical for every backend: @@ -458,7 +451,7 @@ async fn drive( let _ingress = bound.network_mediation_source(); assert_eq!(bound.host_gateway_ip(), None); let confirmed = bound.confirm().await?; - confirmed.evidence().validate(&sandbox_ctx().identity)?; + confirmed.confirmation().validate(&sandbox_ctx().identity)?; confirmed.into_boundary().start_agent().await } @@ -536,12 +529,12 @@ async fn one_driver_runs_both_backends() { } #[test] -fn confirmation_constructor_rejects_incomplete_evidence() { - let mut evidence = confirmation_evidence(); - evidence.seccomp.cancellation = false; +fn confirmation_constructor_rejects_unenforced_property() { + let mut confirmation = confirmation(); + confirmation.properties.egress_interception.enforced = false; let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - evidence, + confirmation, &workload_identity(), ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -559,7 +552,7 @@ fn confirmation_constructor_rejects_another_workload_identity() { .unwrap(); let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), - confirmation_evidence(), + confirmation(), &expected, ); assert!(matches!(result, Err(BackendError::Confirm(_)))); @@ -842,16 +835,16 @@ fn workload_identity_rejects_root_and_normalizes_groups() { } #[test] -fn confirmation_evidence_rejects_identity_or_posture_drift() { +fn confirmation_rejects_identity_or_property_drift() { let expected = workload_identity(); - let evidence = confirmation_evidence(); - evidence.validate(&expected).unwrap(); + let baseline = confirmation(); + baseline.validate(&expected).unwrap(); - let mut drifted = confirmation_evidence(); - drifted.capabilities.effective = 1; + let mut drifted = confirmation(); + drifted.properties.privilege_floor.enforced = false; assert!(drifted.validate(&expected).is_err()); - let mut unmanaged = confirmation_evidence(); + let mut unmanaged = confirmation(); unmanaged.runtime_exit_terminates_workload = false; assert!(unmanaged.validate(&expected).is_err()); @@ -863,7 +856,7 @@ fn confirmation_evidence_rejects_identity_or_posture_drift() { "sha256:test".into(), ) .unwrap(); - assert!(evidence.validate(&different).is_err()); + assert!(baseline.validate(&different).is_err()); } // --------------------------------------------------------------------------- diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 05542fb2bc..c706056621 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -23,8 +23,9 @@ use openshell_core::policy::{ use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BinaryIdentity, BoundaryExitStatus, BoundarySignal, - DriverFenceEvidence, ExecSpec, ResolveError, SandboxConfirmEvidence, + BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, + BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, OuterFenceGuarantees, + ResolveError, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -42,6 +43,145 @@ pub const STREAM_STDIN_CLOSED: u8 = 4; pub const STREAM_NETWORK_DECISION: u8 = 5; pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024; +/// Capability masks measured from `/proc//status` by the `OpenShell` +/// co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityEvidence { + pub inheritable: u64, + pub permitted: u64, + pub effective: u64, + pub bounding: u64, + pub ambient: u64, +} + +impl CapabilityEvidence { + #[must_use] + pub const fn is_empty(self) -> bool { + self.inheritable == 0 + && self.permitted == 0 + && self.effective == 0 + && self.bounding == 0 + && self.ambient == 0 + } +} + +/// Active seccomp notification and socket-broker measurements specific to the +/// `OpenShell` co-located runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "each independently measured kernel operation is reported explicitly" +)] +pub struct SeccompEvidence { + pub new_listener: bool, + pub notification_round_trip: bool, + pub id_validation: bool, + pub addfd_send: bool, + pub retained_socket_operation: bool, + pub proc_fd_identity: bool, + pub task_memory_read: bool, + pub task_memory_write: bool, + pub cancellation: bool, +} + +/// Mechanism-specific audit evidence for the native Linux sandbox adapter. +/// +/// This schema belongs to this backend rather than the generic isolation +/// interface. The host-side backend validates it before constructing a +/// backend-neutral `ConfirmedBoundary`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "audit evidence preserves independently measured security results" +)] +pub struct NativeLinuxSandboxAuditEvidence { + pub capabilities: CapabilityEvidence, + pub no_new_privileges: bool, + pub sandbox_dumpable: bool, + pub child_dumpable: bool, + pub core_limit_zero: bool, + pub native_architecture: String, + pub kernel_release: String, + pub seccomp: SeccompEvidence, + pub landlock_abi: u32, + pub landlock_allow_deny: bool, + pub udp_dns_round_trip: bool, + pub tcp_dns_round_trip: bool, + pub tcp_allow_round_trip: bool, + pub tcp_deny_round_trip: bool, +} + +impl NativeLinuxSandboxAuditEvidence { + /// Validate the complete mechanism-specific posture required by this backend. + pub fn validate(&self) -> Result<(), BackendError> { + let complete = self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero + && !self.native_architecture.is_empty() + && !self.kernel_release.is_empty() + && self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.id_validation + && self.seccomp.addfd_send + && self.seccomp.retained_socket_operation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write + && self.seccomp.cancellation + && self.landlock_abi >= 3 + && self.landlock_allow_deny + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip; + if complete { + Ok(()) + } else { + Err(BackendError::Confirm( + "native Linux sandbox audit evidence is incomplete".to_string(), + )) + } + } + + /// Project backend measurements into the common property contract. + #[must_use] + pub fn properties(&self) -> BoundaryProperties { + BoundaryProperties { + filesystem_confinement: EnforcedProperty::new( + self.landlock_abi >= 3 && self.landlock_allow_deny, + format!("landlock-v{}", self.landlock_abi), + ), + egress_interception: EnforcedProperty::new( + self.seccomp.new_listener + && self.seccomp.notification_round_trip + && self.seccomp.addfd_send + && self.udp_dns_round_trip + && self.tcp_dns_round_trip + && self.tcp_allow_round_trip + && self.tcp_deny_round_trip, + "seccomp-notify", + ), + request_attribution: EnforcedProperty::new( + self.seccomp.id_validation + && self.seccomp.proc_fd_identity + && self.seccomp.task_memory_read + && self.seccomp.task_memory_write, + "seccomp-notify-procfs", + ), + privilege_floor: EnforcedProperty::new( + self.capabilities.is_empty() + && self.no_new_privileges + && !self.sandbox_dumpable + && self.child_dumpable + && self.core_limit_zero, + "linux-capability-free", + ), + } + } +} + /// Ephemeral identity of the supervisor process that owns one sandbox runtime. /// /// The supervisor generates this value in memory and presents it on every @@ -286,8 +426,8 @@ pub struct SandboxRuntimeDescriptor { /// example pod UID, VM generation, or container ID). #[serde(default)] pub resource_claims: std::collections::BTreeMap, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the validated outer network fence. + pub outer_fence: OuterFenceGuarantees, } impl fmt::Debug for SandboxRuntimeDescriptor { @@ -301,7 +441,7 @@ impl fmt::Debug for SandboxRuntimeDescriptor { .field("tls", &self.tls) .field("host_gateway_ip", &self.host_gateway_ip) .field("resource_claims", &self.resource_claims) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .finish() } } @@ -354,8 +494,8 @@ pub struct BoundaryConfig { pub resource_claim_files: std::collections::BTreeMap, /// Exact identity already applied by the runtime to the sandbox process. pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Concrete outer-fence evidence validated by the driver. - pub driver_fence: DriverFenceEvidence, + /// Backend-neutral projection of the validated outer network fence. + pub outer_fence: OuterFenceGuarantees, /// Driver-resolved environment exposed only to workload processes. #[serde(default)] pub child_env: std::collections::HashMap, @@ -383,7 +523,7 @@ impl fmt::Debug for BoundaryConfig { .field("resource_claims", &self.resource_claims) .field("resource_claim_files", &self.resource_claim_files) .field("workload_identity", &self.workload_identity) - .field("driver_fence", &self.driver_fence) + .field("outer_fence", &self.outer_fence) .field("child_env_keys", &self.child_env.keys().collect::>()) .finish() } @@ -712,8 +852,9 @@ pub enum Response { snapshot: SessionSnapshotWire, }, Confirmed { - /// Measured capability-free posture produced before workload launch. - evidence: Box, + /// Backend-neutral properties and backend-owned audit evidence produced + /// before workload launch. + confirmation: Box, }, Started { process_id: String, @@ -1196,6 +1337,61 @@ pub enum FrameError { mod tests { use super::*; + fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { + NativeLinuxSandboxAuditEvidence { + capabilities: CapabilityEvidence { + inheritable: 0, + permitted: 0, + effective: 0, + bounding: 0, + ambient: 0, + }, + no_new_privileges: true, + sandbox_dumpable: false, + child_dumpable: true, + core_limit_zero: true, + native_architecture: "x86_64".to_string(), + kernel_release: "6.12.0".to_string(), + seccomp: SeccompEvidence { + new_listener: true, + notification_round_trip: true, + id_validation: true, + addfd_send: true, + retained_socket_operation: true, + proc_fd_identity: true, + task_memory_read: true, + task_memory_write: true, + cancellation: true, + }, + landlock_abi: 6, + landlock_allow_deny: true, + udp_dns_round_trip: true, + tcp_dns_round_trip: true, + tcp_allow_round_trip: true, + tcp_deny_round_trip: true, + } + } + + #[test] + fn native_linux_audit_evidence_projects_backend_neutral_properties() { + let audit = complete_audit_evidence(); + audit.validate().unwrap(); + let properties = audit.properties(); + assert!(properties.filesystem_confinement.enforced); + assert_eq!(properties.filesystem_confinement.mechanism, "landlock-v6"); + assert!(properties.egress_interception.enforced); + assert!(properties.request_attribution.enforced); + assert!(properties.privilege_floor.enforced); + } + + #[test] + fn native_linux_audit_evidence_rejects_mechanism_failure() { + let mut audit = complete_audit_evidence(); + audit.seccomp.addfd_send = false; + assert!(audit.validate().is_err()); + assert!(!audit.properties().egress_interception.enforced); + } + #[test] fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() { for encoded in [ diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 1b56d6df9d..16840ca577 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -116,7 +116,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { let resource_claims = runtime_descriptor.resource_claims.clone(); let generation = runtime_descriptor.generation.clone(); let session_id = runtime_descriptor.session_id; - let driver_fence = runtime_descriptor.driver_fence.clone(); + let outer_fence = runtime_descriptor.outer_fence.clone(); let client = Arc::new(BoundaryClient::new( runtime_descriptor, self.sandbox_bearer.clone(), @@ -149,7 +149,7 @@ impl IsolationBackend for OpenShellRuntimeBackend { generation, session_id, resource_claims, - driver_fence, + outer_fence, })) } } @@ -181,7 +181,9 @@ fn validate_runtime_descriptor( )); } validate_resource_claims(&runtime_descriptor.resource_claims)?; - runtime_descriptor.driver_fence.validate()?; + runtime_descriptor + .outer_fence + .validate(&runtime_descriptor.generation)?; match &runtime_descriptor.transport { SandboxTransport::Unix { socket_path } => { validate_socket_path(socket_path)?; @@ -292,7 +294,7 @@ struct RemoteBound { generation: String, session_id: openshell_core::SandboxSessionId, resource_claims: std::collections::BTreeMap, - driver_fence: openshell_isolation_interface::contract::DriverFenceEvidence, + outer_fence: openshell_isolation_interface::contract::OuterFenceGuarantees, } #[async_trait] @@ -307,21 +309,34 @@ impl BoundBoundary for RemoteBound { async fn confirm(self: Box) -> Result { let response = self.client.call_idempotent(Request::Confirm).await?; - let Response::Confirmed { evidence } = response else { - return Err(unexpected_response("confirmed_with_evidence", &response)); + let Response::Confirmed { confirmation } = response else { + return Err(unexpected_response("confirmed", &response)); }; - if evidence.generation != self.generation - || evidence.session_id != self.session_id - || evidence.resource_claims != self.resource_claims - || evidence.driver_fence != self.driver_fence + if confirmation.generation != self.generation + || confirmation.session_id != self.session_id + || confirmation.resource_claims != self.resource_claims + || confirmation.outer_fence != self.outer_fence { return Err(BackendError::Confirm( - "sandbox confirmation generation, session, resource claims, or driver fence do not match runtime descriptor" + "sandbox confirmation generation, session, resource claims, or outer fence do not match runtime descriptor" .to_string(), )); } - self.client.start_credential_monitor(); - ConfirmedBoundary::try_new( + let audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).map_err(|error| { + BackendError::Confirm(format!( + "decode native Linux sandbox audit evidence: {error}" + )) + })?; + audit.validate()?; + if confirmation.properties != audit.properties() { + return Err(BackendError::Confirm( + "sandbox confirmation properties do not match native Linux audit evidence" + .to_string(), + )); + } + let client = self.client.clone(); + let confirmed = ConfirmedBoundary::try_new( Box::new(RemoteReady { client: self.client, agent: self.agent, @@ -330,9 +345,11 @@ impl BoundBoundary for RemoteBound { ca_file_paths: self.ca_file_paths, provider_credentials: self.provider_credentials, }), - *evidence, + *confirmation, &self.identity, - ) + )?; + client.start_credential_monitor(); + Ok(confirmed) } } @@ -1924,11 +1941,20 @@ mod tests { FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "test-generation".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + use openshell_isolation_interface::contract::OuterFenceGuarantee; + + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + "test-generation", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + b"test-vm-fence", + ) + .unwrap() } #[tokio::test] @@ -1952,6 +1978,7 @@ mod tests { mediation_failures: Arc, mediation_ready: bool, provider_environment_generation: u64, + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, } type TestGrpcStream = Pin< @@ -1981,6 +2008,7 @@ mod tests { let requests = self.requests.clone(); let mediation_ready = self.mediation_ready; let provider_environment_generation = self.provider_environment_generation; + let confirmation = self.confirmation.clone(); let (outbound, outbound_rx) = tokio::sync::mpsc::channel(1); tokio::spawn(async move { let mut frame = Vec::new(); @@ -2024,7 +2052,7 @@ mod tests { }, }, Request::Confirm => Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(confirmation), }, Request::OpenMediation if mediation_ready => Response::MediationReady, Request::OpenMediation => Response::Error { @@ -2129,6 +2157,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -2193,6 +2222,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; tokio::spawn(async move { tonic::transport::Server::builder() @@ -2286,6 +2316,7 @@ mod tests { mediation_failures: server_failures.clone(), mediation_ready: true, provider_environment_generation: 0, + confirmation: test_confirmation(), }; tokio::spawn(async move { tonic::transport::Server::builder() @@ -2340,6 +2371,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -2467,7 +2499,7 @@ mod tests { tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), } } @@ -2500,12 +2532,25 @@ mod tests { else { return; }; - serve_test_grpc(Box::new(stream), expected_token).await; + serve_test_grpc_with_confirmation( + Box::new(stream), + expected_token, + test_confirmation(), + ) + .await; }); (address, task) } async fn serve_test_grpc(stream: BoundaryDuplexStream, expected_token: String) { + serve_test_grpc_with_confirmation(stream, expected_token, test_confirmation()).await; + } + + async fn serve_test_grpc_with_confirmation( + stream: BoundaryDuplexStream, + expected_token: String, + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + ) { let service = TestGrpcBoundary { wait_for_half_close: false, expected_token, @@ -2513,6 +2558,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation, }; tonic::transport::Server::builder() .add_service(IsolationBoundaryServer::new(service)) @@ -2552,12 +2598,9 @@ mod tests { } } - fn test_confirmation_evidence() - -> openshell_isolation_interface::contract::SandboxConfirmEvidence { - openshell_isolation_interface::contract::SandboxConfirmEvidence { - generation: "test-generation".to_string(), - identity: sandbox().identity, - capabilities: openshell_isolation_interface::contract::CapabilityEvidence { + fn test_confirmation() -> openshell_isolation_interface::contract::BoundaryConfirmation { + let audit = crate::boundary_protocol::NativeLinuxSandboxAuditEvidence { + capabilities: crate::boundary_protocol::CapabilityEvidence { inheritable: 0, permitted: 0, effective: 0, @@ -2570,7 +2613,7 @@ mod tests { core_limit_zero: true, native_architecture: std::env::consts::ARCH.to_string(), kernel_release: "test".to_string(), - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: crate::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -2587,14 +2630,124 @@ mod tests { tcp_dns_round_trip: true, tcp_allow_round_trip: true, tcp_deny_round_trip: true, + }; + openshell_isolation_interface::contract::BoundaryConfirmation { + generation: "test-generation".to_string(), + identity: sandbox().identity, + properties: audit.properties(), authenticated_supervisor: true, session_id: test_session_id(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), runtime_exit_terminates_workload: true, resource_claims: std::collections::BTreeMap::new(), + backend_audit: serde_json::to_value(audit).expect("serialize audit evidence"), } } + async fn assert_remote_confirmation_rejected( + confirmation: openshell_isolation_interface::contract::BoundaryConfirmation, + ) { + let certificate = test_certificate(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind confirmation test server"); + let address = listener.local_addr().expect("confirmation test address"); + let expected_token = "a".repeat(32); + let server_config = certificate.server_config; + let server_token = expected_token.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept confirmation client"); + let stream = tokio_rustls::TlsAcceptor::from(server_config) + .accept(stream) + .await + .expect("accept confirmation TLS"); + serve_test_grpc_with_confirmation(Box::new(stream), server_token, confirmation).await; + }); + + let descriptor = tls_runtime_descriptor(address, certificate.client_tls); + let outer_fence = descriptor.outer_fence.clone(); + let context = sandbox(); + let client = Arc::new(BoundaryClient::new( + descriptor, + test_bearer(&expected_token), + )); + let bound = RemoteBound { + client: client.clone(), + agent: context.agent, + policy: context.policy, + sandbox_id: context.sandbox_id, + mediation: Arc::new(RemoteNetworkMediation { + client: client.clone(), + }), + host_gateway_ip: None, + ca_file_paths: Arc::new(std::sync::Mutex::new(None)), + provider_credentials: + openshell_core::provider_credentials::ProviderCredentialState::from_environment( + 0, + HashMap::new(), + HashMap::new(), + HashMap::new(), + ), + identity: context.identity, + generation: "test-generation".to_string(), + session_id: test_session_id(), + resource_claims: std::collections::BTreeMap::new(), + outer_fence, + }; + + assert!(matches!( + Box::new(bound).confirm().await, + Err(BackendError::Confirm(_)) + )); + assert!( + !client.credential_monitor_started.load(Ordering::Acquire), + "credential monitoring must start only after confirmation succeeds" + ); + server.abort(); + } + + #[tokio::test] + async fn remote_confirm_rejects_invalid_native_audit_before_monitoring() { + let mut confirmation = test_confirmation(); + let mut audit: crate::boundary_protocol::NativeLinuxSandboxAuditEvidence = + serde_json::from_value(confirmation.backend_audit.clone()).expect("decode test audit"); + audit.seccomp.notification_round_trip = false; + confirmation.backend_audit = serde_json::to_value(audit).expect("encode test audit"); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_property_projection_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + confirmation.properties.egress_interception.mechanism = "untrusted projection".to_string(); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_outer_fence_generation_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + confirmation.outer_fence.generation = "other-generation".to_string(); + + assert_remote_confirmation_rejected(confirmation).await; + } + + #[tokio::test] + async fn remote_confirm_rejects_outer_fence_digest_mismatch_before_monitoring() { + let mut confirmation = test_confirmation(); + let different_fence = + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + "test-generation", + confirmation.outer_fence.established.iter().copied(), + b"different evidence", + ) + .expect("construct different test evidence"); + confirmation.outer_fence.evidence_digest = different_fence.evidence_digest; + + assert_remote_confirmation_rejected(confirmation).await; + } + #[test] fn runtime_descriptor_debug_redacts_trust_anchor() { let certificate = test_certificate(); @@ -2609,7 +2762,7 @@ mod tests { tls: certificate.client_tls.clone(), host_gateway_ip: Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; let debug = format!("{runtime_descriptor:?}"); assert!(debug.contains("")); @@ -2629,7 +2782,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2651,7 +2804,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; assert!(matches!( validate_runtime_descriptor(&runtime_descriptor, &sandbox()), @@ -2673,7 +2826,7 @@ mod tests { tls: test_certificate().client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }; validate_runtime_descriptor(&runtime_descriptor, &sandbox()) .expect("TCP runtime descriptor should be valid"); @@ -2709,7 +2862,7 @@ mod tests { .await .expect("TLS request"), Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } ); server.abort(); @@ -2833,6 +2986,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { loop { @@ -2867,7 +3021,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), )); @@ -2904,6 +3058,7 @@ mod tests { mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 50, + confirmation: test_confirmation(), }; let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); @@ -3058,7 +3213,7 @@ mod tests { tls: certificate.client_tls, host_gateway_ip: None, resource_claims: std::collections::BTreeMap::new(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), }, test_bearer(&"a".repeat(32)), ); diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs index 9e82bfe0e1..df40832d4e 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/credential_renewal.rs @@ -66,6 +66,7 @@ async fn renewing_boundary_client( mediation_failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), mediation_ready: false, provider_environment_generation: 0, + confirmation: test_confirmation(), }, expected_token: Arc::new(std::sync::RwLock::new("a".repeat(32))), failures: Arc::new(std::sync::atomic::AtomicUsize::new(0)), diff --git a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs index d131c3ffa0..0ad9c7b441 100644 --- a/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs +++ b/crates/openshell-sandbox-backend/src/runtime/tests/network_recovery.rs @@ -119,7 +119,7 @@ impl NetworkPeer { assert!(self.attached.load(Ordering::Acquire)); *self.state.active.lock().unwrap() = Some(self.connection); Response::Confirmed { - evidence: Box::new(test_confirmation_evidence()), + confirmation: Box::new(test_confirmation()), } } Request::AcceptNetwork => { diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 5452ea6efa..f4bb815f95 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -36,9 +36,8 @@ mod linux { }; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ - BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, BoundaryTerminal, - CapabilityEvidence, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, - SandboxConfirmEvidence, + BoundaryConfirmation, BoundaryExec, BoundaryLoopbackConnector, BoundaryProcess, + BoundaryTerminal, ExecSession, LoopbackTarget, ResolvedWorkloadIdentity, }; use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; use openshell_sandbox_backend::mediation::{ @@ -60,11 +59,11 @@ mod linux { use openshell_sandbox_backend::boundary_protocol::{ AgentSpecWire, BinaryIdentityWire, BoundaryConfig, BoundaryErrorKind, BoundaryListener as BoundaryListenerConfig, DnsQueryResultWire, ExecSpecWire, - ExitStatusWire, MediationTimingWire, OutputWindowWire, ProcessKindWire, - ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, STREAM_EXIT, - STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, STREAM_STDOUT, - SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, read_frame, - read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, + ExitStatusWire, MediationTimingWire, NativeLinuxSandboxAuditEvidence, OutputWindowWire, + ProcessKindWire, ProcessSnapshotWire, Request, RequestEnvelope, Response, ResponseEnvelope, + STREAM_EXIT, STREAM_NETWORK_DECISION, STREAM_STDERR, STREAM_STDIN, STREAM_STDIN_CLOSED, + STREAM_STDOUT, SandboxPolicyWire, SessionSnapshotWire, SignalWire, encode_frame, + read_frame, read_stream_frame, validate_resource_claims, write_frame, write_stream_frame, }; const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30); @@ -304,8 +303,8 @@ mod linux { } validate_resource_claims(&config.resource_claims).map_err(|error| error.to_string())?; config - .driver_fence - .validate() + .outer_fence + .validate(&config.generation) .map_err(|error| error.to_string())?; for (claim, path) in &config.resource_claim_files { if !config.resource_claims.contains_key(claim) { @@ -2262,20 +2261,20 @@ mod linux { if let Err(error) = prepared.confirm(&self.process_runtime) { return guest_error(BoundaryErrorKind::Process, error); } - let evidence = match self.measure_confirmation_evidence() { - Ok(evidence) => evidence, + let confirmation = match self.measure_confirmation() { + Ok(confirmation) => confirmation, Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; *state = RuntimeState::Ready(prepared.clone()); Response::Confirmed { - evidence: Box::new(evidence), + confirmation: Box::new(confirmation), } } RuntimeState::Ready(_) | RuntimeState::Running(_) => { - self.measure_confirmation_evidence().map_or_else( + self.measure_confirmation().map_or_else( |error| guest_error(BoundaryErrorKind::Process, error), - |evidence| Response::Confirmed { - evidence: Box::new(evidence), + |confirmation| Response::Confirmed { + confirmation: Box::new(confirmation), }, ) } @@ -2286,7 +2285,7 @@ mod linux { } } - fn measure_confirmation_evidence(&self) -> Result { + fn measure_confirmation(&self) -> Result { validate_running_identity( &self.config.workload_identity, allows_runtime_supplementary_groups(&self.config), @@ -2299,7 +2298,7 @@ mod linux { } let status = std::fs::read_to_string("/proc/self/status") .map_err(|error| format!("read sandbox process status: {error}"))?; - let capabilities = CapabilityEvidence { + let capabilities = openshell_sandbox_backend::boundary_protocol::CapabilityEvidence { inheritable: parse_status_hex(&status, "CapInh")?, permitted: parse_status_hex(&status, "CapPrm")?, effective: parse_status_hex(&status, "CapEff")?, @@ -2320,9 +2319,7 @@ mod linux { // SAFETY: successful getrlimit initialized the value. let core_limit = unsafe { core_limit.assume_init() }; let (native_architecture, kernel_release) = uname_values()?; - Ok(SandboxConfirmEvidence { - generation: self.config.generation.clone(), - identity: self.config.workload_identity.clone(), + let audit = NativeLinuxSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, @@ -2337,11 +2334,24 @@ mod linux { tcp_dns_round_trip: self.qualification.tcp_dns_round_trip, tcp_allow_round_trip: self.qualification.tcp_allow_round_trip, tcp_deny_round_trip: self.qualification.tcp_deny_round_trip, + }; + // The boundary reports mechanism evidence; the authenticated host + // backend validates it before constructing a ConfirmedBoundary. + // Keeping that decision at the verifier also lets lifecycle tests + // exercise the protocol without claiming host-kernel enforcement. + let properties = audit.properties(); + let backend_audit = serde_json::to_value(audit) + .map_err(|error| format!("encode OpenShell sandbox audit evidence: {error}"))?; + Ok(BoundaryConfirmation { + generation: self.config.generation.clone(), + identity: self.config.workload_identity.clone(), + properties, authenticated_supervisor: true, session_id: self.config.session_id, - driver_fence: self.config.driver_fence.clone(), + outer_fence: self.config.outer_fence.clone(), runtime_exit_terminates_workload: true, resource_claims: self.config.resource_claims.clone(), + backend_audit, }) } @@ -3705,7 +3715,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; let debug = format!("{config:?}"); @@ -3856,7 +3866,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4258,16 +4268,25 @@ mod linux { .unwrap() } - fn test_driver_fence() -> openshell_isolation_interface::contract::DriverFenceEvidence { - openshell_isolation_interface::contract::DriverFenceEvidence::Vm { - generation: "generation-1".to_string(), - network_device_count: 0, - } + fn test_outer_fence() -> openshell_isolation_interface::contract::OuterFenceGuarantees { + use openshell_isolation_interface::contract::OuterFenceGuarantee; + + openshell_isolation_interface::contract::OuterFenceGuarantees::from_enforcement_evidence( + "generation-1", + [ + OuterFenceGuarantee::DefaultDenyEgress, + OuterFenceGuarantee::NoUnmanagedEgressPath, + OuterFenceGuarantee::RevocationVerified, + OuterFenceGuarantee::ControllerLossFailsClosed, + ], + b"test-vm-fence", + ) + .unwrap() } fn test_runtime_qualification() -> crate::RuntimeQualification { crate::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: true, notification_round_trip: true, id_validation: true, @@ -4410,7 +4429,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4445,7 +4464,7 @@ mod linux { pod_uid_path, )]), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }; @@ -4485,7 +4504,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, tokio::runtime::Handle::current(), @@ -4660,7 +4679,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), @@ -4978,7 +4997,7 @@ mod linux { resource_claims: std::collections::BTreeMap::new(), resource_claim_files: std::collections::BTreeMap::new(), workload_identity: test_workload_identity(), - driver_fence: test_driver_fence(), + outer_fence: test_outer_fence(), child_env: std::collections::HashMap::new(), }, process_runtime.handle().clone(), diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4928fe6f40..4166d8d2c1 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -34,7 +34,7 @@ pub mod sandbox; reason = "qualification preserves independently exercised security results" )] pub struct RuntimeQualification { - pub seccomp: openshell_isolation_interface::contract::SeccompEvidence, + pub seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence, pub landlock_abi: u32, pub landlock_allow_deny: bool, pub udp_dns_round_trip: bool, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab16..dbdc8733ec 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -239,7 +239,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi wait_killable_recv: notification.wait_killable_recv, }; let qualification = openshell_sandbox::RuntimeQualification { - seccomp: openshell_isolation_interface::contract::SeccompEvidence { + seccomp: openshell_sandbox_backend::boundary_protocol::SeccompEvidence { new_listener: notification.notification_round_trip(), notification_round_trip: notification.notification_round_trip(), id_validation: notification.notification_round_trip(), diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index f3da87aeef..81d0b1630f 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -25,7 +25,7 @@ //! The risk is contained by existing sandbox layers: //! - **Privilege drop**: `CAP_NET_ADMIN` is not granted, so all write operations //! (add/delete routes, addresses, interfaces) fail with `EPERM` regardless. -//! - **Driver outer fence**: direct workload egress is rejected outside this +//! - **Outer network fence**: direct workload egress is rejected outside this //! process by Docker network-none, Kubernetes `NetworkPolicy`, or a NIC-less //! VM. //!