diff --git a/Cargo.lock b/Cargo.lock index 3cfc62f13f..d563cd5ad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4556,10 +4556,8 @@ dependencies = [ "miette", "openshell-container-runtime", "openshell-driver-sdk", - "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", - "openshell-sandbox-backend", "opentelemetry", "opentelemetry_sdk", "prost-types", @@ -4687,10 +4685,8 @@ dependencies = [ "openshell-container-runtime", "openshell-crypto", "openshell-driver-sdk", - "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", - "openshell-sandbox-backend", "opentelemetry", "opentelemetry_sdk", "prost-types", @@ -4793,11 +4789,8 @@ dependencies = [ "openshell-crypto", "openshell-driver-podman", "openshell-driver-sdk", - "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", - "openshell-policy", - "openshell-sandbox-backend", "openshell-vfio", "opentelemetry", "opentelemetry_sdk", @@ -4895,6 +4888,7 @@ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ "openshell-core", + "openshell-driver-sdk", "serde", "serde_json", "sha2 0.10.9", @@ -5106,6 +5100,7 @@ dependencies = [ "libc", "openshell-core", "openshell-crypto", + "openshell-driver-sdk", "openshell-isolation-interface", "prost", "prost-types", diff --git a/crates/openshell-core/src/isolation_registration.rs b/crates/openshell-core/src/isolation_registration.rs index 8f820e302d..8c9fc5ddf3 100644 --- a/crates/openshell-core/src/isolation_registration.rs +++ b/crates/openshell-core/src/isolation_registration.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; /// Reserved selection for drivers that launch the built-in sandbox runtime. -pub const BUILTIN_BACKEND_NAME: &str = "openshell-sandbox"; +pub use openshell_driver_sdk::legacy_contract::BUILTIN_BACKEND_NAME; /// Trusted byte-stream endpoint. Generation-specific TLS is supplied by the driver. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index 0fec49ba63..ab386d9608 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -16,9 +16,7 @@ path = "src/main.rs" [dependencies] openshell-container-runtime = { path = "../openshell-container-runtime" } -openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "otel", "clap"] } -openshell-isolation-interface = { path = "../openshell-isolation-interface" } -openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "otel", "clap", "legacy-contract"] } openshell-otel = { path = "../openshell-otel" } opentelemetry = { workspace = true } diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 2644ec5650..44708790b3 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -11,11 +11,11 @@ use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; use std::path::PathBuf; -use openshell_isolation_interface::contract::{ +use openshell_driver_sdk::legacy_contract::GPU_RESOURCE_CLAIM; +use openshell_driver_sdk::legacy_contract::{ BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, }; -use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; -use openshell_sandbox_backend::boundary_protocol::{ +use openshell_driver_sdk::legacy_contract::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; @@ -167,9 +167,8 @@ mod tests { #[test] fn provisioning_binds_container_and_image_claims() { let session_id = openshell_driver_sdk::SandboxSessionId::new(); - let tls = - openshell_sandbox_backend::boundary_protocol::generate_sandbox_tls_material(session_id) - .unwrap(); + let tls = openshell_driver_sdk::legacy_contract::generate_sandbox_tls_material(session_id) + .unwrap(); let provisioned = DockerBoundarySpec { boundary_id: "sandbox-1".to_string(), generation: "generation-1".to_string(), diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 7d7ec87c12..5695f2b781 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -42,6 +42,11 @@ use openshell_driver_sdk::driver_utils::{ SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, }; use openshell_driver_sdk::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_driver_sdk::legacy_contract::ResolvedWorkloadIdentity; +use openshell_driver_sdk::legacy_contract::{ + BoundaryConfig, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, + SandboxTlsServerConfig, generate_sandbox_tls_material, +}; use openshell_driver_sdk::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, @@ -64,11 +69,6 @@ use openshell_driver_sdk::{ AppArmorProfile, Error, ImagePullPolicy, NetworkSupervisorTrustBundle, Result as CoreResult, UpstreamProxyConfig, }; -use openshell_isolation_interface::contract::ResolvedWorkloadIdentity; -use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, - SandboxTlsServerConfig, generate_sandbox_tls_material, -}; use opentelemetry::trace::TraceContextExt as _; use sha2::{Digest as _, Sha256}; use std::borrow::Cow; @@ -127,9 +127,10 @@ const SUPERVISOR_PROXY_CA_BUNDLE_MOUNT_PATH: &str = "/.openshell/supervisor/upstream-proxy-ca-bundle.pem"; const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = openshell_container_runtime::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; -const DRIVER_ADMITTED_BACKEND: &str = openshell_sandbox_backend::BACKEND_NAME; +const DRIVER_ADMITTED_BACKEND: &str = openshell_driver_sdk::legacy_contract::BUILTIN_BACKEND_NAME; const LABEL_ISOLATION_BACKEND: &str = "openshell.ai/isolation-backend"; -const LABEL_ISOLATION_BACKEND_OPEN_SHELL: &str = openshell_sandbox_backend::BACKEND_NAME; +const LABEL_ISOLATION_BACKEND_OPEN_SHELL: &str = + openshell_driver_sdk::legacy_contract::BUILTIN_BACKEND_NAME; const LABEL_ISOLATION_ROLE: &str = "openshell.ai/isolation-role"; const LABEL_ISOLATION_ROLE_SANDBOX: &str = "sandbox"; const LABEL_ISOLATION_ROLE_SUPERVISOR: &str = "supervisor"; @@ -5083,8 +5084,14 @@ async fn stage_docker_supervisor_bundle( Some(RemoveContainerOptionsBuilder::default().force(true).build()), ) .await; - let runtime_initiated = read_docker_runtime_descriptor(&sandbox.id, config).await? - .is_some_and(|descriptor| matches!(descriptor.transport, openshell_sandbox_backend::boundary_protocol::SandboxTransport::RuntimeInitiatedUnix { .. })); + let runtime_initiated = read_docker_runtime_descriptor(&sandbox.id, config) + .await? + .is_some_and(|descriptor| { + matches!( + descriptor.transport, + openshell_driver_sdk::legacy_contract::SandboxTransport::RuntimeInitiatedUnix { .. } + ) + }); let mut mounts = vec![Mount { target: Some(SUPERVISOR_STATE_MOUNT_PATH.to_string()), source: Some(docker_supervisor_volume_name(sandbox, config)), @@ -5523,8 +5530,14 @@ async fn spawn_docker_control_process( &config.upstream_proxy, config.proxy_ca_bundle.is_some(), )); - let runtime_initiated = read_docker_runtime_descriptor(&sandbox.id, config).await? - .is_some_and(|descriptor| matches!(descriptor.transport, openshell_sandbox_backend::boundary_protocol::SandboxTransport::RuntimeInitiatedUnix { .. })); + let runtime_initiated = read_docker_runtime_descriptor(&sandbox.id, config) + .await? + .is_some_and(|descriptor| { + matches!( + descriptor.transport, + openshell_driver_sdk::legacy_contract::SandboxTransport::RuntimeInitiatedUnix { .. } + ) + }); command.extend(docker_network_trust_supervisor_args( config.network_trust.as_ref(), )); @@ -6368,7 +6381,7 @@ fn build_container_create_body_for_image( // exactly as written so short names never expand to host domains. dns_search: Some(vec![".".to_string()]), tmpfs: Some(HashMap::from([( - openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_DIR.to_string(), + openshell_driver_sdk::legacy_contract::SUPERVISOR_CA_RUNTIME_DIR.to_string(), format!( "rw,noexec,nosuid,nodev,size=1m,uid={},gid={},mode=0755", workload_identity.uid, workload_identity.gid diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index f3f8036b3a..2999d8e8d3 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -3783,7 +3783,7 @@ fn build_container_create_body_limits_writable_runtime_storage_to_supervisor_ca( assert_eq!(tmpfs.len(), 1); assert_eq!( - tmpfs.get(openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_DIR), + tmpfs.get(openshell_driver_sdk::legacy_contract::SUPERVISOR_CA_RUNTIME_DIR), Some(&"rw,noexec,nosuid,nodev,size=1m,uid=1000,gid=1000,mode=0755".to_string()) ); assert!(!tmpfs.contains_key("/run")); diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index dcae6dd947..202dbead2b 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -15,11 +15,9 @@ name = "openshell-driver-podman" path = "src/main.rs" [dependencies] -openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "otel", "clap"] } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "otel", "clap", "legacy-contract"] } openshell-container-runtime = { path = "../openshell-container-runtime" } openshell-otel = { path = "../openshell-otel" } -openshell-isolation-interface = { path = "../openshell-isolation-interface" } -openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } tar = "0.4" tempfile = "3" uuid = { workspace = true } diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 590c5b8a9a..d81cd74c74 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -59,7 +59,7 @@ const CONTAINER_PREFIX: &str = "openshell-"; const VOLUME_PREFIX: &str = "openshell-sandbox-"; const PODMAN_WORKLOAD_CONTROL_PATHS: &[&str] = &[ "/.openshell", - openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_ROOT, + openshell_driver_sdk::legacy_contract::SUPERVISOR_CA_RUNTIME_ROOT, ]; /// Secret name prefix for per-sandbox gateway JWTs. @@ -1591,7 +1591,7 @@ pub struct IsolationSpecInput<'a> { /// Gateway-owned immutable destination-trust artifact. It is deliberately /// mounted only into the trusted supervisor spec. pub network_trust: Option<&'a NetworkSupervisorTrustBundle>, - pub identity: &'a openshell_isolation_interface::contract::ResolvedWorkloadIdentity, + pub identity: &'a openshell_driver_sdk::legacy_contract::ResolvedWorkloadIdentity, } pub struct IsolationSpecs { @@ -1686,7 +1686,7 @@ pub fn build_isolation_specs( workload.mounts.push(Mount { kind: "tmpfs".into(), source: "tmpfs".into(), - destination: openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_ROOT.into(), + destination: openshell_driver_sdk::legacy_contract::SUPERVISOR_CA_RUNTIME_ROOT.into(), options: vec![ "rw".into(), "noexec".into(), @@ -1744,7 +1744,7 @@ pub fn build_isolation_specs( } supervisor.env.insert( openshell_driver_sdk::sandbox_env::ADMITTED_ISOLATION_BACKEND.into(), - openshell_sandbox_backend::BACKEND_NAME.into(), + openshell_driver_sdk::legacy_contract::BUILTIN_BACKEND_NAME.into(), ); // The supervisor runs as the resolved non-root workload identity. Keep // generated interception CA material under its writable /run tmpfs rather @@ -1986,7 +1986,7 @@ mod tests { name: "trust-pair".into(), ..Default::default() }; - let identity = openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + let identity = openshell_driver_sdk::legacy_contract::ResolvedWorkloadIdentity::new( 1000, 1001, Vec::new(), @@ -2092,7 +2092,7 @@ mod tests { "openshell-sandbox".into(), )); config.userns = Some("auto".into()); - let identity = openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + let identity = openshell_driver_sdk::legacy_contract::ResolvedWorkloadIdentity::new( 1000, 1001, vec![2000], @@ -2181,7 +2181,7 @@ mod tests { assert_eq!(specs.supervisor.apparmor_profile, None); let default_identity = - openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + openshell_driver_sdk::legacy_contract::ResolvedWorkloadIdentity::new( 1000, 1000, Vec::new(), @@ -2247,7 +2247,8 @@ mod tests { .mounts .iter() .find(|mount| { - mount.destination == openshell_sandbox_backend::SUPERVISOR_CA_RUNTIME_ROOT + mount.destination + == openshell_driver_sdk::legacy_contract::SUPERVISOR_CA_RUNTIME_ROOT }) .expect("workload supervisor CA mount"); assert_eq!(supervisor_ca_mount.kind, "tmpfs"); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 5435577544..e3863e4e48 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -1670,7 +1670,7 @@ impl PodmanComputeDriver { sandbox: &DriverSandbox, image: &str, image_user: &str, - ) -> Result + ) -> Result { #[derive(serde::Serialize)] struct InspectionSpec<'a> { @@ -4470,7 +4470,7 @@ mod tests { fn restart_responses() -> Vec { let mut archive = tar::Builder::new(Vec::new()); - let identity = openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + let identity = openshell_driver_sdk::legacy_contract::ResolvedWorkloadIdentity::new( 1000, 1001, vec![], diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index fa837aef8b..86cdb4c746 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -9,14 +9,14 @@ use std::io::Read; use std::path::PathBuf; use openshell_driver_sdk::ComputeDriverError; -use openshell_driver_sdk::proto::compute::v1::DriverSandbox; -use openshell_isolation_interface::contract::{ProvisioningEvidence, ResolvedWorkloadIdentity}; -use openshell_sandbox_backend::ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM; -use openshell_sandbox_backend::boundary_protocol::{ +use openshell_driver_sdk::legacy_contract::ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM; +use openshell_driver_sdk::legacy_contract::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, generate_sandbox_tls_material, }; +use openshell_driver_sdk::legacy_contract::{ProvisioningEvidence, ResolvedWorkloadIdentity}; +use openshell_driver_sdk::proto::compute::v1::DriverSandbox; use serde::{Deserialize, Serialize}; pub const LABEL_ROLE: &str = "openshell.ai/isolation-role"; diff --git a/crates/openshell-driver-sdk/Cargo.toml b/crates/openshell-driver-sdk/Cargo.toml index faca990fec..32b238739a 100644 --- a/crates/openshell-driver-sdk/Cargo.toml +++ b/crates/openshell-driver-sdk/Cargo.toml @@ -17,6 +17,7 @@ base64 = { workspace = true } clap = { workspace = true, optional = true } jsonwebtoken = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } +rcgen = { workspace = true, optional = true } prost = { workspace = true } prost-types = { workspace = true } tonic = { workspace = true, features = ["channel"] } @@ -51,6 +52,9 @@ default = [] telemetry = [] ## Strict Ed25519 JWT issuance and verification for authenticated sandbox sessions. jwt = ["dep:openshell-crypto", "dep:jsonwebtoken", "dep:zeroize"] +## The Rust-defined form of the documents a driver hands to the supervisor and the +## sandbox boundary. Legacy: removed when its replacement ships. +legacy-contract = ["jwt", "dep:rcgen"] ## Conversions to `openshell-otel` types. otel = ["dep:openshell-otel"] ## Command-line flags shared by compute drivers. diff --git a/crates/openshell-driver-sdk/README.md b/crates/openshell-driver-sdk/README.md index 86123b1646..1b46f4b622 100644 --- a/crates/openshell-driver-sdk/README.md +++ b/crates/openshell-driver-sdk/README.md @@ -24,8 +24,56 @@ only by the Docker, Podman, and VM drivers belongs in - `error`, `paths`, `net`, `settings`, `telemetry`, `time`, `progress`, `image`, `sandbox_session`, `sandbox_generation`, `proto_struct`, `external_driver_socket`, `e2fsprogs`: small helpers. +- `legacy_contract` (feature `legacy-contract`): the Rust-defined documents a + driver hands to the supervisor and the sandbox boundary. See below. ## Dependencies The crate depends only on workspace crates and crates.io, and never on `openshell-core`. + +## Legacy driver contract + +A compute driver provisions two things for the supervisor and the sandbox +boundary besides the `ComputeDriver` gRPC service: the JSON documents below, and +a few constants. `legacy_contract` holds the Rust types for them so that a +driver does not depend on `openshell-core` or `openshell-sandbox-backend`. + +| Type | Produced by | Read by | +|---|---|---| +| `jwt::SandboxLaunchAuthentication` | Gateway | Driver | +| `jwt::SupervisorAuthBundle` | Gateway, inside the launch authentication | Supervisor | +| `SandboxRuntimeDescriptor` | Driver | Supervisor | +| `BoundaryConfig` | Driver | Sandbox boundary | + +The module also holds the backend name, the resource claim keys and the +supervisor CA paths that drivers set, and `generate_sandbox_tls_material`. + +This is the format that exists today, not a specification to build on. It is +expected to be replaced by a language-neutral one, so the module accepts no new +fields and is removed once the replacement has shipped and no supported release +reads it. Readers reject unknown fields, so a writer must not add any, and the +documents carry no version. + +`tests/legacy_contract/examples/` holds the golden documents, and +`tests/legacy_contract.rs` checks that each decodes and re-encodes to the same +JSON and that readers reject invalid ones. A change that alters an encoding +fails there. + +### Replacing it + +Options considered for the replacement are JSON Schema for these documents, and +protobuf messages written as proto3 JSON. JSON Schema describes today's bytes +exactly but is a second description of concepts that protos already define; +protobuf avoids that, and a driver that serializes generated types would emit +proto3 JSON, which existing readers reject. Nothing is decided. + +A lower-risk path than reading both formats is to write both: a driver writes +the new format under a new file name beside the legacy one, and the supervisor +and sandbox prefer the new file when present. Every document is regenerated at +each create and restart, so there is no stored data to migrate. The gateway's +launch authentication travels over gRPC, so it can gain a typed message field +beside the opaque bytes instead of a new JSON form. Docker rewrites its own +`BoundaryConfig` and descriptor in place at restart; it would first need to keep +its restart state privately, as Podman does, so that a driver never parses a +file written by an older one. diff --git a/crates/openshell-driver-sdk/src/legacy_contract/boundary.rs b/crates/openshell-driver-sdk/src/legacy_contract/boundary.rs new file mode 100644 index 0000000000..7b397d3037 --- /dev/null +++ b/crates/openshell-driver-sdk/src/legacy_contract/boundary.rs @@ -0,0 +1,424 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The documents a compute driver hands to the supervisor and the sandbox +//! boundary: the runtime descriptor, the boundary bootstrap configuration, and +//! the transport, listener and TLS types they contain. + +use std::fmt; +use std::path::PathBuf; + +use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyUsagePurpose}; +use serde::{Deserialize, Serialize}; + +use super::BUILTIN_BACKEND_NAME; +use super::identity::{ + BackendDescriptor, BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, +}; +use crate::sandbox_session::SandboxSessionId; + +/// Shortest accepted route handle, in bytes. +pub const MIN_ROUTE_HANDLE_LEN: usize = 16; +/// Longest accepted route handle, in bytes. +pub const MAX_ROUTE_HANDLE_LEN: usize = 64; + +/// Validate an opaque route handle: 16 to 64 lowercase ASCII letters, digits, +/// or hyphens. +pub fn validate_route_handle(handle: &str) -> Result<(), BackendError> { + let valid = (MIN_ROUTE_HANDLE_LEN..=MAX_ROUTE_HANDLE_LEN).contains(&handle.len()) + && handle + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if valid { + Ok(()) + } else { + Err(BackendError::Descriptor( + "runtime route handle must be 16 to 64 lowercase letters, digits, or hyphens" + .to_string(), + )) + } +} + +/// Backend-owned transport for a driver-provisioned runtime. The serialized +/// variants preserve existing runtime descriptors independently of operator +/// endpoint registration in core. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SandboxTransport { + Unix { + socket_path: PathBuf, + }, + Tcp { + /// Logical HTTP authority used for routing. + authority: String, + /// Concrete connection candidates pinned by the operator or driver. + addresses: Vec, + }, + Vsock { + guest_cid: u32, + port: u32, + }, + /// The supervisor accepts a connection from its sandbox runtime. TLS and + /// gRPC roles stay unchanged. + RuntimeInitiatedTcp { + authority: String, + listen_address: std::net::SocketAddr, + }, + RuntimeInitiatedUnix { + socket_path: PathBuf, + }, + RuntimeInitiatedVsock { + guest_cid: u32, + port: u32, + }, + /// The runtime dials a supervisor shared by several sandbox instances and + /// names this instance's opaque route handle before the pinned TLS + /// handshake. + /// Only a host that owns the shared listener can serve this transport. + RuntimeInitiatedRouted { + authority: String, + route: String, + }, +} + +impl SandboxTransport { + pub fn validate(&self) -> Result<(), &'static str> { + let valid = match self { + Self::Unix { socket_path } | Self::RuntimeInitiatedUnix { socket_path } => { + crate::paths::is_posix_absolute(socket_path) + } + Self::Tcp { + authority, + addresses, + } => { + !authority.is_empty() + && !addresses.is_empty() + && addresses + .iter() + .all(|address| address.port() != 0 && !address.ip().is_unspecified()) + } + Self::RuntimeInitiatedTcp { + authority, + listen_address, + } => !authority.is_empty() && listen_address.port() != 0, + Self::Vsock { guest_cid, port } | Self::RuntimeInitiatedVsock { guest_cid, port } => { + *guest_cid >= 3 && *guest_cid != u32::MAX && *port != 0 && *port != u32::MAX + } + Self::RuntimeInitiatedRouted { authority, route } => { + !authority.is_empty() && validate_route_handle(route).is_ok() + } + }; + if valid { + Ok(()) + } else { + Err("invalid boundary control transport endpoint") + } + } +} + +/// Supervisor-side, generation-pinned TLS server authentication. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SandboxTlsClientConfig { + pub server_name: String, + pub trust_anchor_pem: String, +} + +impl fmt::Debug for SandboxTlsClientConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SandboxTlsClientConfig") + .field("server_name", &self.server_name) + .field("trust_anchor_pem", &"") + .finish() + } +} + +/// Sandbox-side TLS server files staged by the compute driver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SandboxTlsServerConfig { + pub certificate_chain_path: PathBuf, + pub private_key_path: PathBuf, +} + +/// Fresh, server-only TLS material for one sandbox session. +#[derive(Clone)] +pub struct SandboxTlsMaterial { + pub server_name: String, + pub trust_anchor_pem: String, + pub certificate_chain_pem: String, + pub private_key_pem: String, +} + +impl fmt::Debug for SandboxTlsMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SandboxTlsMaterial") + .field("server_name", &self.server_name) + .field("trust_anchor_pem", &"") + .field("certificate_chain_pem", &"") + .field("private_key_pem", &"") + .finish() + } +} + +/// Generate a generation-pinned TLS server identity for the Sandbox Protocol. +/// +/// The CA private key is local to this function and is discarded after the +/// server leaf is signed. The JWT, rather than certificate time, determines +/// caller authorization and renewal. +pub fn generate_sandbox_tls_material( + session_id: SandboxSessionId, +) -> Result { + let server_name = format!("sandbox.{session_id}.openshell.internal"); + let ca_key = openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519) + .map_err(|error| BackendError::Descriptor(format!("generate sandbox CA key: {error}")))?; + let mut ca_params = CertificateParams::default(); + ca_params.not_before = rcgen::date_time_ymd(1975, 1, 1); + ca_params.not_after = rcgen::date_time_ymd(4096, 1, 1); + ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(DnType::CommonName, "OpenShell sandbox session CA"); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let ca = openshell_crypto::pki::self_signed(ca_params, &ca_key).map_err(|error| { + BackendError::Descriptor(format!("generate sandbox CA certificate: {error}")) + })?; + + let sandbox_key = + openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519).map_err(|error| { + BackendError::Descriptor(format!("generate sandbox TLS server key: {error}")) + })?; + let mut sandbox_params = CertificateParams::new(vec![server_name.clone()]) + .map_err(|error| BackendError::Descriptor(format!("build sandbox certificate: {error}")))?; + sandbox_params.not_before = rcgen::date_time_ymd(1975, 1, 1); + sandbox_params.not_after = rcgen::date_time_ymd(4096, 1, 1); + sandbox_params + .distinguished_name + .push(DnType::CommonName, "OpenShell sandbox runtime"); + sandbox_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let sandbox = openshell_crypto::pki::signed_by(sandbox_params, &sandbox_key, &ca, &ca_key) + .map_err(|error| { + BackendError::Descriptor(format!("sign sandbox TLS server certificate: {error}")) + })?; + + Ok(SandboxTlsMaterial { + server_name, + trust_anchor_pem: ca.pem(), + certificate_chain_pem: sandbox.pem(), + private_key_pem: sandbox_key + .serialize_pem() + .map_err(|error| BackendError::Descriptor(format!("export sandbox key: {error}")))?, + }) +} + +/// Boundary-side listener provisioned by a compute driver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum BoundaryListener { + /// TLS over a private Unix socket shared with the host supervisor. + Unix { + socket_path: PathBuf, + tls: SandboxTlsServerConfig, + }, + /// TLS over TCP. An unspecified IP is valid for the sandbox bind. + TlsTcp { + address: std::net::SocketAddr, + tls: SandboxTlsServerConfig, + }, + /// TLS over guest `AF_VSOCK`. + Vsock { + control_port: u32, + tls: SandboxTlsServerConfig, + }, + /// The boundary dials the supervisor's listener and serves TLS and gRPC + /// over that outbound connection. Only driver-resolved addresses are used. + RuntimeInitiatedTlsTcp { + /// Stable logical Service authority, for diagnostics. + authority: String, + /// Driver-resolved supervisor endpoints, tried in order. + addresses: Vec, + tls: SandboxTlsServerConfig, + /// Route handle sent before TLS to a supervisor shared by several + /// sandbox instances. Absent for a dedicated supervisor. + #[serde(default, skip_serializing_if = "Option::is_none")] + route: Option, + }, + /// Dial the supervisor over the driver's protected shared Unix socket. + RuntimeInitiatedUnix { + socket_path: PathBuf, + tls: SandboxTlsServerConfig, + }, + /// Dial the host supervisor through the VM's vsock device. + RuntimeInitiatedVsock { + host_port: u32, + tls: SandboxTlsServerConfig, + }, +} + +/// Public verification key staged in the sandbox's immutable auth bundle. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GatewayVerificationKey { + pub key_id: String, + pub public_key_pem: String, +} + +/// Protected runtime descriptor consumed by `openshell-supervisor`. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SandboxRuntimeDescriptor { + /// Stable identity of the boundary, normally the sandbox ID. + pub boundary_id: String, + /// Immutable driver-owned workload generation. + pub generation: String, + /// Fresh gateway-issued identity for this exact launch. + pub session_id: SandboxSessionId, + /// Immutable numeric identity already applied to the sandbox workload. + pub workload_identity: ResolvedWorkloadIdentity, + /// Driver-provisioned byte-stream endpoint. + pub transport: SandboxTransport, + /// Per-generation pinned TLS server identity. + pub tls: SandboxTlsClientConfig, + /// Trusted dial target for well-known host-gateway aliases, when the + /// network supervisor cannot use the boundary's resolver view. + #[serde(default)] + pub host_gateway_ip: Option, + /// Driver-specific immutable resource coordinates bound at attach (for + /// example pod UID, VM generation, or container ID). + #[serde(default)] + pub resource_claims: std::collections::BTreeMap, + /// Backend-neutral projection of the validated outer network fence. + pub provisioning_evidence: ProvisioningEvidence, +} + +impl fmt::Debug for SandboxRuntimeDescriptor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SandboxRuntimeDescriptor") + .field("boundary_id", &self.boundary_id) + .field("generation", &self.generation) + .field("session_id", &self.session_id) + .field("transport", &self.transport) + .field("tls", &self.tls) + .field("host_gateway_ip", &self.host_gateway_ip) + .field("resource_claims", &self.resource_claims) + .field("provisioning_evidence", &self.provisioning_evidence) + .finish() + } +} + +impl SandboxRuntimeDescriptor { + /// Encode this runtime configuration for the `openshell-sandbox` backend. + pub fn backend_descriptor(&self) -> Result { + let payload = serde_json::to_vec(self).map_err(|error| { + BackendError::Descriptor(format!("encode runtime descriptor: {error}")) + })?; + Ok(BackendDescriptor { + backend_name: BUILTIN_BACKEND_NAME.to_string(), + payload, + }) + } +} + +/// Protected bootstrap configuration consumed by `openshell-sandbox`. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BoundaryConfig { + /// Stable identity expected in every authenticated request. + pub boundary_id: String, + /// Immutable driver-owned workload generation. + pub generation: String, + /// Fresh gateway-issued identity for this exact launch. + pub session_id: SandboxSessionId, + /// Monotonic signed supervisor replacement within this generation. + pub session_rotation: crate::jwt::SessionRotation, + /// Durable authorization epoch required in every Sandbox Protocol JWT. + pub auth_epoch: crate::jwt::CredentialEpoch, + /// Gateway identity expected in Sandbox Protocol JWTs. + pub gateway_id: String, + /// Immutable current and staged-next gateway verification keys. + pub verification_keys: Vec, + /// Driver-provisioned listener. + pub listener: BoundaryListener, + /// Immutable coordinates the boundary requires from the control-side + /// runtime descriptor before accepting attachment. + #[serde(default)] + pub resource_claims: std::collections::BTreeMap, + /// Driver-provisioned, read-only runtime evidence for resource claims. + /// + /// Each entry maps a claim key to an absolute file whose trimmed contents + /// must equal the corresponding value in `resource_claims` before the + /// boundary opens its listener. Kubernetes uses this to bind a one-use + /// bootstrap bundle to the admitted workload Pod UID exposed by the + /// Downward API. Other drivers may leave the map empty. + #[serde(default)] + pub resource_claim_files: std::collections::BTreeMap, + /// Exact identity already applied by the runtime to the sandbox process. + pub workload_identity: ResolvedWorkloadIdentity, + /// Backend-neutral projection of the validated outer network fence. + pub provisioning_evidence: ProvisioningEvidence, + /// Driver-resolved environment exposed only to workload processes. + #[serde(default)] + pub child_env: std::collections::HashMap, +} + +impl fmt::Debug for BoundaryConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundaryConfig") + .field("boundary_id", &self.boundary_id) + .field("generation", &self.generation) + .field("session_id", &self.session_id) + .field("session_rotation", &self.session_rotation) + .field("auth_epoch", &self.auth_epoch) + .field("gateway_id", &self.gateway_id) + .field( + "verification_key_ids", + &self + .verification_keys + .iter() + .map(|key| key.key_id.as_str()) + .collect::>(), + ) + .field("listener", &self.listener) + .field("resource_claims", &self.resource_claims) + .field("resource_claim_files", &self.resource_claim_files) + .field("workload_identity", &self.workload_identity) + .field("provisioning_evidence", &self.provisioning_evidence) + .field("child_env_keys", &self.child_env.keys().collect::>()) + .finish() + } +} + +impl BoundaryConfig { + /// Serialize the protected driver-owned boundary configuration. + pub fn encode(&self) -> Result, BackendError> { + serde_json::to_vec(self) + .map_err(|error| BackendError::Descriptor(format!("encode boundary config: {error}"))) + } +} + +/// Validate driver-specific immutable coordinates before a boundary binds them. +/// +/// Claim values are opaque to the common protocol, but empty or +/// whitespace-bearing identifiers cannot safely distinguish runtime objects. +pub fn validate_resource_claims( + claims: &std::collections::BTreeMap, +) -> Result<(), BackendError> { + for (key, value) in claims { + if key.is_empty() || key.chars().any(char::is_whitespace) { + return Err(BackendError::Descriptor( + "boundary resource-claim keys must be non-empty and contain no whitespace" + .to_string(), + )); + } + if value.is_empty() || value.chars().any(char::is_whitespace) { + return Err(BackendError::Descriptor(format!( + "boundary resource claim {key:?} must be non-empty and contain no whitespace" + ))); + } + } + Ok(()) +} diff --git a/crates/openshell-driver-sdk/src/legacy_contract/identity.rs b/crates/openshell-driver-sdk/src/legacy_contract/identity.rs new file mode 100644 index 0000000000..1ee488d007 --- /dev/null +++ b/crates/openshell-driver-sdk/src/legacy_contract/identity.rs @@ -0,0 +1,308 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Identity, evidence, descriptor and error types shared by compute drivers and +//! isolation backends. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Classified failures at the common contract boundary. +/// +/// An error never advances the lifecycle or authorizes an operation. +#[derive(Debug)] +pub enum BackendError { + /// Descriptor missing, malformed, unsupported, or mismatched against admission. + Descriptor(String), + /// Authenticated attachment rejection (incompatible or already-bound resource). + Denied(String), + /// Boundary temporarily unavailable. + Unavailable(String), + /// The selected backend does not implement an optional contract operation. + Unsupported(String), + /// Attachment-phase failure (establishment or mediation bring-up). + Attach(String), + /// Readiness confirmation failed (do not start workload code). + Confirm(String), + /// Process start or exec failure. + Process(String), + /// Abnormal boundary or workload loss, or an operation against an inactive + /// boundary. + Terminated(String), +} + +/// Coarse, machine-readable classification of a [`BackendError`] for supervisor +/// status mapping. The error's variant and message carry the structured context +/// (which operation failed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendErrorKind { + /// Descriptor or backend mismatch. + Invalid, + /// Authenticated attachment rejection. + Denied, + /// Transient inability to serve an operation. + Unavailable, + /// The selected backend does not implement the requested optional operation. + Unsupported, + /// Attachment, confirmation, start, or runtime operation failure. + Failed, + /// Abnormal boundary/workload loss, or an operation against an inactive + /// boundary. + Terminated, +} + +impl BackendError { + /// The machine-readable kind for this error. + #[must_use] + pub fn kind(&self) -> BackendErrorKind { + match self { + Self::Descriptor(_) => BackendErrorKind::Invalid, + Self::Denied(_) => BackendErrorKind::Denied, + Self::Unavailable(_) => BackendErrorKind::Unavailable, + Self::Unsupported(_) => BackendErrorKind::Unsupported, + Self::Attach(_) | Self::Confirm(_) | Self::Process(_) => BackendErrorKind::Failed, + Self::Terminated(_) => BackendErrorKind::Terminated, + } + } +} + +impl fmt::Display for BackendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Descriptor(m) => write!(f, "descriptor error: {m}"), + Self::Denied(m) => write!(f, "attachment denied: {m}"), + Self::Unavailable(m) => write!(f, "boundary unavailable: {m}"), + Self::Unsupported(m) => write!(f, "operation unsupported: {m}"), + Self::Attach(m) => write!(f, "attachment failed: {m}"), + Self::Confirm(m) => write!(f, "confirmation failed: {m}"), + Self::Process(m) => write!(f, "process error: {m}"), + Self::Terminated(m) => write!(f, "boundary terminated: {m}"), + } + } +} + +impl std::error::Error for BackendError {} + +/// Why an identity resolution failed. Resolution failure fails closed: the +/// mediation service denies and audits the connection; it never authorizes. +#[derive(Debug, Clone)] +pub enum ResolveError { + /// No process owns the connection (stale or unknown attribution). + NotFound, + /// Resolution attempted but could not produce trustworthy identity. + Failed(String), +} + +impl fmt::Display for ResolveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotFound => write!(f, "connection owner not found"), + Self::Failed(m) => write!(f, "identity resolution failed: {m}"), + } + } +} + +impl std::error::Error for ResolveError {} + +/// The common isolation backend descriptor envelope. +/// +/// The compute driver supplies one for the selected isolation backend. The opaque +/// payload identifies an existing resource or carries the trusted prepared +/// inputs the backend needs to establish one during `attach`; its protection +/// and resource lifecycle remain owned by the compute driver. +#[derive(Debug, Clone)] +pub struct BackendDescriptor { + /// The backend the supervisor must instantiate. + pub backend_name: String, + /// Backend-specific attachment data. + pub payload: Vec, +} + +/// Exact non-root identity selected before the immutable workload is created. +/// +/// Validation and normalization run in [`ResolvedWorkloadIdentity::new`] and +/// again when a value is decoded from a serialized descriptor: deserialization +/// routes through the same constructor, so an invalid identity fails to decode +/// instead of bypassing the contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawResolvedWorkloadIdentity")] +pub struct ResolvedWorkloadIdentity { + /// Effective and real user ID used by sandbox and all workload children. + pub uid: u32, + /// Primary group ID used by sandbox and all workload children. + pub gid: u32, + /// Sorted, unique supplementary groups inherited unchanged by children. + pub supplementary_gids: Vec, + /// Driver-defined resolution source (`policy`, `template`, or `image`). + pub source: String, + /// Digest of the immutable image/rootfs/config used for resolution. + pub resource_digest: String, +} + +impl ResolvedWorkloadIdentity { + /// Validate and construct a final workload identity. + pub fn new( + uid: u32, + gid: u32, + mut supplementary_gids: Vec, + source: String, + resource_digest: String, + ) -> Result { + if uid == 0 || gid == 0 || supplementary_gids.contains(&0) { + return Err(BackendError::Descriptor( + "workload identity must not contain UID or GID zero".to_string(), + )); + } + if source.trim().is_empty() || resource_digest.trim().is_empty() { + return Err(BackendError::Descriptor( + "workload identity source and resource digest are required".to_string(), + )); + } + supplementary_gids.retain(|supplementary_gid| *supplementary_gid != gid); + supplementary_gids.sort_unstable(); + supplementary_gids.dedup(); + Ok(Self { + uid, + gid, + supplementary_gids, + source, + resource_digest, + }) + } +} + +/// Unvalidated wire shape of [`ResolvedWorkloadIdentity`], existing only so +/// serde decoding can route through [`ResolvedWorkloadIdentity::new`]. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawResolvedWorkloadIdentity { + uid: u32, + gid: u32, + supplementary_gids: Vec, + source: String, + resource_digest: String, +} + +impl TryFrom for ResolvedWorkloadIdentity { + type Error = BackendError; + + fn try_from(raw: RawResolvedWorkloadIdentity) -> Result { + Self::new( + raw.uid, + raw.gid, + raw.supplementary_gids, + raw.source, + raw.resource_digest, + ) + } +} + +/// Commitment to provisioning facts validated by the trusted compute driver. +/// +/// The driver validates its native placement and enforcement requirements before +/// constructing this binding. The digest includes the runtime generation, so +/// confirmation cannot reuse evidence from another launch. It is a binding, +/// not independent proof that the backend enforces policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProvisioningEvidence { + pub generation: String, + pub evidence_digest: Sha256Digest, +} + +impl ProvisioningEvidence { + /// Bind already-validated native provisioning evidence to one generation. + pub fn from_enforcement_evidence( + generation: impl Into, + native_evidence: &[u8], + ) -> Result { + let generation = generation.into(); + if generation.is_empty() || native_evidence.is_empty() { + return Err(BackendError::Descriptor( + "provisioning 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, + evidence_digest: Sha256Digest::compute(&binding), + }) + } + + /// Reject evidence bound to a different launch. + pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { + if !self.generation.is_empty() && self.generation == expected_generation { + Ok(()) + } else { + Err(BackendError::Confirm( + "provisioning evidence belongs to another generation".to_string(), + )) + } + } +} + +/// A SHA-256 digest, kept typed so the identity field is not coupled to its +/// textual encoding or forced to repeat the algorithm in its name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Sha256Digest([u8; 32]); + +impl TryFrom for Sha256Digest { + type Error = ResolveError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl From for String { + fn from(value: Sha256Digest) -> Self { + value.to_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] { + &self.0 + } +} + +impl fmt::Display for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl FromStr for Sha256Digest { + type Err = ResolveError; + + fn from_str(value: &str) -> Result { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(ResolveError::Failed( + "SHA-256 digest must contain 64 hexadecimal characters".to_string(), + )); + } + let mut bytes = [0_u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| { + ResolveError::Failed("SHA-256 digest contains non-hexadecimal data".to_string()) + })?; + } + Ok(Self(bytes)) + } +} diff --git a/crates/openshell-driver-sdk/src/legacy_contract/mod.rs b/crates/openshell-driver-sdk/src/legacy_contract/mod.rs new file mode 100644 index 0000000000..14dbced922 --- /dev/null +++ b/crates/openshell-driver-sdk/src/legacy_contract/mod.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The current, Rust-defined form of what a compute driver hands to the +//! supervisor and the sandbox boundary. +//! +//! # Legacy +//! +//! This module describes a format that is expected to be replaced by a +//! language-neutral specification. It exists so that drivers can stop depending +//! on `openshell-core` and `openshell-sandbox-backend` today. It accepts no new +//! fields and is removed when the replacement format has shipped and the +//! supported releases no longer read this one. See `rfc/` for the migration. +//! +//! The documents are serialized as JSON by their `serde` implementations. +//! Readers reject unknown fields, so a writer must not add any. + +/// Stable isolation backend name implemented by `openshell-sandbox`. Reserved: +/// no externally registered backend may use it. +pub const BUILTIN_BACKEND_NAME: &str = "openshell-sandbox"; + +/// Resource claim set by compute drivers when the workload requests GPU access. +pub const GPU_RESOURCE_CLAIM: &str = "openshell.gpu"; + +/// Resource claim set when the runtime may retain supplementary groups in +/// addition to the image-derived workload identity. +pub const ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM: &str = + "openshell.identity.allow_extra_supplementary_groups"; + +/// Memory-backed parent used for supervisor CA material. +pub const SUPERVISOR_CA_RUNTIME_ROOT: &str = "/run/openshell-supervisor-ca"; + +/// Workload-visible directory for the supervisor's public HTTPS interception +/// certificate and combined trust bundle. +/// +/// Keeping the material below the mount root lets runtimes that cannot assign +/// tmpfs ownership create this child as the unprivileged sandbox identity. +pub const SUPERVISOR_CA_RUNTIME_DIR: &str = "/run/openshell-supervisor-ca/material"; + +#[cfg(feature = "legacy-contract")] +pub mod boundary; +#[cfg(feature = "legacy-contract")] +pub mod identity; + +#[cfg(feature = "legacy-contract")] +pub use boundary::*; +#[cfg(feature = "legacy-contract")] +pub use identity::*; diff --git a/crates/openshell-driver-sdk/src/lib.rs b/crates/openshell-driver-sdk/src/lib.rs index d0de1b483f..34a610f87f 100644 --- a/crates/openshell-driver-sdk/src/lib.rs +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -23,6 +23,7 @@ pub mod gpu; pub mod image; #[cfg(feature = "jwt")] pub mod jwt; +pub mod legacy_contract; pub mod net; pub mod network_trust; pub mod paths; diff --git a/crates/openshell-driver-sdk/tests/legacy_contract.rs b/crates/openshell-driver-sdk/tests/legacy_contract.rs new file mode 100644 index 0000000000..2024461e07 --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract.rs @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The legacy driver-contract documents against golden JSON examples. +//! +//! The examples in `tests/legacy_contract/examples` are the bytes that existing +//! drivers write and existing readers accept. Each must decode and re-encode to +//! the same JSON, so a change to a type that alters its encoding fails here. + +#![cfg(feature = "legacy-contract")] + +use openshell_driver_sdk::jwt::{SandboxLaunchAuthentication, SupervisorAuthBundle}; +use openshell_driver_sdk::legacy_contract::{BoundaryConfig, SandboxRuntimeDescriptor}; +use serde_json::{Value, json}; + +macro_rules! example { + ($name:literal) => { + ( + $name, + include_str!(concat!("legacy_contract/examples/", $name)), + ) + }; +} + +const EXAMPLES: &[(&str, &str)] = &[ + example!("BoundaryConfig.runtime-initiated-unix.json"), + example!("BoundaryConfig.runtime-initiated-routed-tcp.json"), + example!("SandboxRuntimeDescriptor.runtime-initiated-unix.json"), + example!("SandboxRuntimeDescriptor.runtime-initiated-routed.json"), + example!("SandboxRuntimeDescriptor.tcp.json"), + example!("SandboxRuntimeDescriptor.vsock.json"), + example!("SupervisorAuthBundle.minimal.json"), + example!("SupervisorAuthBundle.full.json"), + example!("SandboxLaunchAuthentication.json"), +]; + +fn document_type(file: &str) -> &str { + file.split('.').next().expect("example file name") +} + +fn example(file: &str) -> Value { + let (_, text) = EXAMPLES + .iter() + .find(|(name, _)| *name == file) + .unwrap_or_else(|| panic!("no example {file}")); + serde_json::from_str(text).expect("example is JSON") +} + +/// Decode `document` as the named type and encode it again. +fn round_trip(document_type: &str, document: &Value) -> Result { + fn through(document: &Value) -> Result + where + T: serde::de::DeserializeOwned + serde::Serialize, + { + let value: T = serde_json::from_value(document.clone()).map_err(|e| e.to_string())?; + serde_json::to_value(&value).map_err(|e| e.to_string()) + } + match document_type { + "BoundaryConfig" => through::(document), + "SandboxRuntimeDescriptor" => through::(document), + "SupervisorAuthBundle" => through::(document), + "SandboxLaunchAuthentication" => through::(document), + other => panic!("no type registered for {other}"), + } +} + +#[test] +fn examples_round_trip_unchanged() { + for (file, text) in EXAMPLES { + let document: Value = serde_json::from_str(text).expect("example is JSON"); + let written = round_trip(document_type(file), &document) + .unwrap_or_else(|error| panic!("{file} rejected: {error}")); + assert_eq!(written, document, "{file} changed when re-encoded"); + } +} + +/// Apply `mutate` to an example and require the reader to reject the result. +fn assert_rejected(file: &str, mutate: impl FnOnce(&mut Value)) { + let mut document = example(file); + mutate(&mut document); + assert!( + round_trip(document_type(file), &document).is_err(), + "{file}: the reader accepted an invalid document" + ); +} + +#[test] +fn readers_reject_unknown_top_level_fields() { + for file in [ + "BoundaryConfig.runtime-initiated-unix.json", + "SandboxRuntimeDescriptor.tcp.json", + "SupervisorAuthBundle.minimal.json", + "SandboxLaunchAuthentication.json", + ] { + assert_rejected(file, |document| { + document["unexpected"] = json!(true); + }); + } +} + +#[test] +fn readers_reject_missing_required_fields() { + assert_rejected("BoundaryConfig.runtime-initiated-unix.json", |document| { + document.as_object_mut().unwrap().remove("auth_epoch"); + }); + assert_rejected("SandboxRuntimeDescriptor.tcp.json", |document| { + document.as_object_mut().unwrap().remove("tls"); + }); +} + +#[test] +fn readers_reject_unknown_listener_and_transport_kinds() { + assert_rejected("BoundaryConfig.runtime-initiated-unix.json", |document| { + document["listener"]["kind"] = json!("carrier-pigeon"); + }); + assert_rejected("SandboxRuntimeDescriptor.tcp.json", |document| { + document["transport"]["kind"] = json!("carrier-pigeon"); + }); +} + +#[test] +fn readers_reject_nil_and_noncanonical_session_ids() { + for invalid in [ + "00000000-0000-0000-0000-000000000000", + "7D3F0A52-9C1E-4B6A-8F21-3A5E6C9D0B14", + "7d3f0a529c1e4b6a8f213a5e6c9d0b14", + ] { + assert_rejected("BoundaryConfig.runtime-initiated-unix.json", |document| { + document["session_id"] = json!(invalid); + }); + } +} + +#[test] +fn readers_reject_a_workload_identity_that_uses_root() { + assert_rejected("BoundaryConfig.runtime-initiated-unix.json", |document| { + document["workload_identity"]["uid"] = json!(0); + }); +} + +#[test] +fn readers_reject_a_malformed_evidence_digest() { + assert_rejected("BoundaryConfig.runtime-initiated-unix.json", |document| { + document["provisioning_evidence"]["evidence_digest"] = json!("not-hex"); + }); +} + +/// Rules that serde decoding does not apply and a later validator does. +#[test] +fn some_rules_are_enforced_after_decoding() { + let mut document = example("SandboxRuntimeDescriptor.runtime-initiated-routed.json"); + document["transport"]["route"] = json!("short"); + let descriptor: SandboxRuntimeDescriptor = serde_json::from_value(document).unwrap(); + assert!(descriptor.transport.validate().is_err()); + + let mut document = example("SupervisorAuthBundle.full.json"); + document["traces_otlp_tls"] = json!(9); + let bundle: SupervisorAuthBundle = serde_json::from_value(document).unwrap(); + assert!(bundle.validate().is_err()); +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/BoundaryConfig.runtime-initiated-routed-tcp.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/BoundaryConfig.runtime-initiated-routed-tcp.json new file mode 100644 index 0000000000..a03ae0fd18 --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/BoundaryConfig.runtime-initiated-routed-tcp.json @@ -0,0 +1,52 @@ +{ + "boundary_id": "sbx-0002", + "generation": "g0000000000000001", + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "session_rotation": 2, + "auth_epoch": 3, + "gateway_id": "gw-test", + "verification_keys": [ + { + "key_id": "k1", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAexampleexampleexampleexampleexampleexample=\n-----END PUBLIC KEY-----\n" + }, + { + "key_id": "k2", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAexampleexampleexampleexampleexampleexample=\n-----END PUBLIC KEY-----\n" + } + ], + "listener": { + "kind": "runtime-initiated-tls-tcp", + "authority": "supervisor.openshell.svc", + "addresses": [ + "10.0.0.5:8443", + "[fd00::5]:8443" + ], + "tls": { + "certificate_chain_path": "/.openshell/tls/server.crt", + "private_key_path": "/.openshell/tls/server.key" + }, + "route": "rt-0123456789abcdef0123456789abcdef" + }, + "resource_claims": { + "kubernetes.pod_uid": "3f1c2d4e-0000-4000-8000-000000000001" + }, + "resource_claim_files": { + "kubernetes.pod_uid": "/etc/podinfo/uid" + }, + "workload_identity": { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [ + 1001, + 2000 + ], + "source": "image", + "resource_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "provisioning_evidence": { + "generation": "g0000000000000001", + "evidence_digest": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "child_env": {} +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/BoundaryConfig.runtime-initiated-unix.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/BoundaryConfig.runtime-initiated-unix.json new file mode 100644 index 0000000000..74c6cb012c --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/BoundaryConfig.runtime-initiated-unix.json @@ -0,0 +1,41 @@ +{ + "boundary_id": "sbx-0001", + "generation": "g0000000000000001", + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "session_rotation": 1, + "auth_epoch": 1, + "gateway_id": "gw-test", + "verification_keys": [ + { + "key_id": "k1", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAexampleexampleexampleexampleexampleexample=\n-----END PUBLIC KEY-----\n" + } + ], + "listener": { + "kind": "runtime-initiated-unix", + "socket_path": "/.openshell/channel/control/supervisor/control.sock", + "tls": { + "certificate_chain_path": "/.openshell/tls/server.crt", + "private_key_path": "/.openshell/tls/server.key" + } + }, + "resource_claims": { + "docker.container_id": "c0ffee0123", + "openshell.gpu": "true" + }, + "workload_identity": { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [], + "source": "image", + "resource_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "provisioning_evidence": { + "generation": "g0000000000000001", + "evidence_digest": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "child_env": { + "HOME": "/home/sandbox" + }, + "resource_claim_files": {} +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxLaunchAuthentication.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxLaunchAuthentication.json new file mode 100644 index 0000000000..e5e2628aed --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxLaunchAuthentication.json @@ -0,0 +1,151 @@ +{ + "supervisor": { + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "runtime_generation": "g0000000000000001", + "session_rotation": 4, + "auth_epoch": 2, + "gateway_token": "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJnYXRld2F5In0.c2ln", + "gateway_expires_at": 1893456000, + "sandbox_token": "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJzYW5kYm94In0.c2ln", + "sandbox_expires_at": 1893456000, + "ssh_host_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nexample\n-----END OPENSSH PRIVATE KEY-----\n", + "traces_otlp_headers": "authorization=Bearer example", + "ocsf_otlp_headers": "authorization=Bearer example", + "agent_otlp_headers": "authorization=Bearer example", + "otlp_tls": [ + { + "ca_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n" + }, + { + "ca_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n", + "client": { + "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n", + "key_pem": "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n" + } + } + ], + "traces_otlp_tls": 0, + "ocsf_otlp_tls": 1, + "agent_otlp_tls": 1 + }, + "gateway_id": "gw-test", + "verification_keys": [ + { + "key_id": "k1", + "public_key_pem": [ + 45, + 45, + 45, + 45, + 45, + 66, + 69, + 71, + 73, + 78, + 32, + 80, + 85, + 66, + 76, + 73, + 67, + 32, + 75, + 69, + 89, + 45, + 45, + 45, + 45, + 45, + 10, + 77, + 67, + 111, + 119, + 66, + 81, + 89, + 68, + 75, + 50, + 86, + 119, + 65, + 121, + 69, + 65, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 101, + 120, + 97, + 109, + 112, + 108, + 101, + 61, + 10, + 45, + 45, + 45, + 45, + 45, + 69, + 78, + 68, + 32, + 80, + 85, + 66, + 76, + 73, + 67, + 32, + 75, + 69, + 89, + 45, + 45, + 45, + 45, + 45, + 10 + ] + } + ] +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.runtime-initiated-routed.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.runtime-initiated-routed.json new file mode 100644 index 0000000000..1c04af4cf8 --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.runtime-initiated-routed.json @@ -0,0 +1,27 @@ +{ + "boundary_id": "sbx-0002", + "generation": "g0000000000000001", + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "workload_identity": { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [], + "source": "image", + "resource_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "transport": { + "kind": "runtime-initiated-routed", + "authority": "supervisor.openshell.svc", + "route": "rt-0123456789abcdef0123456789abcdef" + }, + "tls": { + "server_name": "sandbox.7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14.openshell.internal", + "trust_anchor_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n" + }, + "host_gateway_ip": null, + "provisioning_evidence": { + "generation": "g0000000000000001", + "evidence_digest": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "resource_claims": {} +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.runtime-initiated-unix.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.runtime-initiated-unix.json new file mode 100644 index 0000000000..4f7e7b332a --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.runtime-initiated-unix.json @@ -0,0 +1,28 @@ +{ + "boundary_id": "sbx-0001", + "generation": "g0000000000000001", + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "workload_identity": { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [], + "source": "image", + "resource_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "transport": { + "kind": "runtime-initiated-unix", + "socket_path": "/run/openshell/supervisor.sock" + }, + "tls": { + "server_name": "sandbox.7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14.openshell.internal", + "trust_anchor_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n" + }, + "host_gateway_ip": "172.17.0.1", + "resource_claims": { + "docker.container_id": "c0ffee0123" + }, + "provisioning_evidence": { + "generation": "g0000000000000001", + "evidence_digest": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + } +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.tcp.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.tcp.json new file mode 100644 index 0000000000..460c304089 --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.tcp.json @@ -0,0 +1,29 @@ +{ + "boundary_id": "sbx-0003", + "generation": "g0000000000000001", + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "workload_identity": { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [], + "source": "image", + "resource_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "transport": { + "kind": "tcp", + "authority": "sandbox.internal", + "addresses": [ + "10.0.0.9:9000" + ] + }, + "tls": { + "server_name": "sandbox.7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14.openshell.internal", + "trust_anchor_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n" + }, + "provisioning_evidence": { + "generation": "g0000000000000001", + "evidence_digest": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "host_gateway_ip": null, + "resource_claims": {} +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.vsock.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.vsock.json new file mode 100644 index 0000000000..b0befc976d --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SandboxRuntimeDescriptor.vsock.json @@ -0,0 +1,27 @@ +{ + "boundary_id": "sbx-0004", + "generation": "g0000000000000001", + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "workload_identity": { + "uid": 1000, + "gid": 1000, + "supplementary_gids": [], + "source": "image", + "resource_digest": "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "transport": { + "kind": "vsock", + "guest_cid": 3, + "port": 1024 + }, + "tls": { + "server_name": "sandbox.7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14.openshell.internal", + "trust_anchor_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n" + }, + "provisioning_evidence": { + "generation": "g0000000000000001", + "evidence_digest": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "host_gateway_ip": null, + "resource_claims": {} +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SupervisorAuthBundle.full.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SupervisorAuthBundle.full.json new file mode 100644 index 0000000000..b0aa2a6282 --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SupervisorAuthBundle.full.json @@ -0,0 +1,29 @@ +{ + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "runtime_generation": "g0000000000000001", + "session_rotation": 4, + "auth_epoch": 2, + "gateway_token": "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJnYXRld2F5In0.c2ln", + "gateway_expires_at": 1893456000, + "sandbox_token": "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJzYW5kYm94In0.c2ln", + "sandbox_expires_at": 1893456000, + "ssh_host_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\nexample\n-----END OPENSSH PRIVATE KEY-----\n", + "traces_otlp_headers": "authorization=Bearer example", + "ocsf_otlp_headers": "authorization=Bearer example", + "agent_otlp_headers": "authorization=Bearer example", + "otlp_tls": [ + { + "ca_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n" + }, + { + "ca_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n", + "client": { + "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIBexample\n-----END CERTIFICATE-----\n", + "key_pem": "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----\n" + } + } + ], + "traces_otlp_tls": 0, + "ocsf_otlp_tls": 1, + "agent_otlp_tls": 1 +} diff --git a/crates/openshell-driver-sdk/tests/legacy_contract/examples/SupervisorAuthBundle.minimal.json b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SupervisorAuthBundle.minimal.json new file mode 100644 index 0000000000..6ed40712f6 --- /dev/null +++ b/crates/openshell-driver-sdk/tests/legacy_contract/examples/SupervisorAuthBundle.minimal.json @@ -0,0 +1,10 @@ +{ + "session_id": "7d3f0a52-9c1e-4b6a-8f21-3a5e6c9d0b14", + "runtime_generation": "g0000000000000001", + "session_rotation": 1, + "auth_epoch": 1, + "gateway_token": "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJnYXRld2F5In0.c2ln", + "gateway_expires_at": 1893456000, + "sandbox_token": "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJzYW5kYm94In0.c2ln", + "sandbox_expires_at": 1893456000 +} diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index 4f01454123..71a5db9701 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -25,11 +25,8 @@ path = "src/bin/openshell-vm-init.rs" [dependencies] openshell-container-runtime = { path = "../openshell-container-runtime" } -openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "clap"] } -openshell-isolation-interface = { path = "../openshell-isolation-interface" } -openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "clap", "legacy-contract"] } openshell-otel = { path = "../openshell-otel", optional = true } -openshell-policy = { path = "../openshell-policy", optional = true } openshell-driver-podman = { path = "../openshell-driver-podman", optional = true } openshell-vfio = { path = "../openshell-vfio", optional = true } @@ -82,7 +79,6 @@ compute-driver = [ "dep:oci-client", "dep:openshell-otel", "openshell-driver-sdk/otel", - "dep:openshell-policy", "dep:openshell-driver-podman", "dep:openshell-vfio", "dep:opentelemetry", diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index ed5a10e1a2..36ddb9f495 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -50,6 +50,11 @@ use openshell_driver_sdk::UpstreamProxyConfig; use openshell_driver_sdk::config::DEFAULT_STOP_TIMEOUT_SECS; use openshell_driver_sdk::driver_mounts; use openshell_driver_sdk::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_driver_sdk::legacy_contract::{ + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, + SandboxTlsClientConfig, SandboxTlsMaterial, SandboxTlsServerConfig, SandboxTransport, + generate_sandbox_tls_material, +}; use openshell_driver_sdk::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, @@ -73,11 +78,6 @@ use openshell_driver_sdk::proto_struct::struct_to_json_value; use openshell_driver_sdk::{ NetworkSupervisorTrustBundle, network_trust::MAX_NETWORK_SUPERVISOR_TRUST_BUNDLE_BYTES, }; -use openshell_sandbox_backend::boundary_protocol::{ - BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, - SandboxTlsClientConfig, SandboxTlsMaterial, SandboxTlsServerConfig, SandboxTransport, - generate_sandbox_tls_material, -}; use openshell_vfio::SysfsRoot; use opentelemetry::trace::TraceContextExt as _; use prost::Message; @@ -225,7 +225,7 @@ const HOST_ADDITIONAL_CA_TEMP_PREFIX: &str = ".additional-ca."; const HOST_ADDITIONAL_CA_TEMP_SUFFIX: &str = ".tmp"; /// The backend this driver admits. VM-specific placement remains inside the /// opaque runtime descriptor. -const DRIVER_ADMITTED_BACKEND: &str = openshell_sandbox_backend::BACKEND_NAME; +const DRIVER_ADMITTED_BACKEND: &str = openshell_driver_sdk::legacy_contract::BUILTIN_BACKEND_NAME; const HOST_SUPERVISOR_BINARY: &str = "host-runtime/openshell-supervisor"; const VM_CONTROL_SOCKET: &str = "control.sock"; const VM_CONTROL_PORT: u32 = 5500; @@ -588,14 +588,15 @@ impl VmDriverConfig { } pub fn validate_sandbox_identity(&self) -> Result<(), String> { - let range = openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID; + let range = openshell_driver_sdk::policy::MIN_SANDBOX_UID + ..=openshell_driver_sdk::policy::MAX_SANDBOX_UID; if let Some(uid) = self.sandbox_uid && !range.contains(&uid) { return Err(format!( "sandbox_uid {uid} is outside the allowed range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID, + openshell_driver_sdk::policy::MIN_SANDBOX_UID, + openshell_driver_sdk::policy::MAX_SANDBOX_UID, )); } if let Some(gid) = self.sandbox_gid @@ -603,8 +604,8 @@ impl VmDriverConfig { { return Err(format!( "sandbox_gid {gid} is outside the allowed range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID, + openshell_driver_sdk::policy::MIN_SANDBOX_UID, + openshell_driver_sdk::policy::MAX_SANDBOX_UID, )); } Ok(()) @@ -7111,19 +7112,20 @@ async fn write_sandbox_owner_state( } fn validate_sandbox_owner_identity(uid: u32, gid: u32) -> Result<(), String> { - let range = openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID; + let range = openshell_driver_sdk::policy::MIN_SANDBOX_UID + ..=openshell_driver_sdk::policy::MAX_SANDBOX_UID; if !range.contains(&uid) { return Err(format!( "uid {uid} is outside the allowed range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID + openshell_driver_sdk::policy::MIN_SANDBOX_UID, + openshell_driver_sdk::policy::MAX_SANDBOX_UID )); } if !range.contains(&gid) { return Err(format!( "gid {gid} is outside the allowed range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID + openshell_driver_sdk::policy::MIN_SANDBOX_UID, + openshell_driver_sdk::policy::MAX_SANDBOX_UID )); } Ok(()) diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index 397b43527e..740d511e4f 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -8,11 +8,11 @@ //! VM claims; `openshell-isolation-interface` and `openshell-sandbox` provide //! the common control and boundary behavior. -use openshell_isolation_interface::contract::{ +use openshell_driver_sdk::legacy_contract::GPU_RESOURCE_CLAIM; +use openshell_driver_sdk::legacy_contract::{ BackendError, ProvisioningEvidence, ResolvedWorkloadIdentity, }; -use openshell_sandbox_backend::GPU_RESOURCE_CLAIM; -use openshell_sandbox_backend::boundary_protocol::{ +use openshell_driver_sdk::legacy_contract::{ BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, }; @@ -145,7 +145,7 @@ impl VmBoundarySpec { #[cfg(test)] mod tests { use super::*; - use openshell_sandbox_backend::boundary_protocol::{ + use openshell_driver_sdk::legacy_contract::{ SandboxTlsClientConfig, SandboxTlsServerConfig, SandboxTransport, generate_sandbox_tls_material, }; diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index a0c3c94a29..0cf08cd206 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core", default-features = false } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["legacy-contract"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index bd328fc0a1..9ce90b53aa 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -8,10 +8,8 @@ //! not a second trait-based backend interface. use std::collections::BTreeMap; -use std::fmt; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; -use std::str::FromStr; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -20,205 +18,19 @@ use tokio::sync::oneshot; pub use openshell_core::SandboxSessionId; pub use openshell_core::policy::SandboxPolicy; +pub use openshell_driver_sdk::legacy_contract::{ + BackendDescriptor, BackendError, BackendErrorKind, ProvisioningEvidence, ResolveError, + ResolvedWorkloadIdentity, Sha256Digest, +}; // ============================================================================ // Errors // ============================================================================ -/// Classified failures at the common contract boundary. -/// -/// An error never advances the lifecycle or authorizes an operation. -#[derive(Debug)] -pub enum BackendError { - /// Descriptor missing, malformed, unsupported, or mismatched against admission. - Descriptor(String), - /// Authenticated attachment rejection (incompatible or already-bound resource). - Denied(String), - /// Boundary temporarily unavailable. - Unavailable(String), - /// The selected backend does not implement an optional contract operation. - Unsupported(String), - /// Attachment-phase failure (establishment or mediation bring-up). - Attach(String), - /// Readiness confirmation failed (do not start workload code). - Confirm(String), - /// Process start or exec failure. - Process(String), - /// Abnormal boundary or workload loss, or an operation against an inactive - /// boundary. - Terminated(String), -} - -/// Coarse, machine-readable classification of a [`BackendError`] for supervisor -/// status mapping. The error's variant and message carry the structured context -/// (which operation failed). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BackendErrorKind { - /// Descriptor or backend mismatch. - Invalid, - /// Authenticated attachment rejection. - Denied, - /// Transient inability to serve an operation. - Unavailable, - /// The selected backend does not implement the requested optional operation. - Unsupported, - /// Attachment, confirmation, start, or runtime operation failure. - Failed, - /// Abnormal boundary/workload loss, or an operation against an inactive - /// boundary. - Terminated, -} - -impl BackendError { - /// The machine-readable kind for this error. - #[must_use] - pub fn kind(&self) -> BackendErrorKind { - match self { - Self::Descriptor(_) => BackendErrorKind::Invalid, - Self::Denied(_) => BackendErrorKind::Denied, - Self::Unavailable(_) => BackendErrorKind::Unavailable, - Self::Unsupported(_) => BackendErrorKind::Unsupported, - Self::Attach(_) | Self::Confirm(_) | Self::Process(_) => BackendErrorKind::Failed, - Self::Terminated(_) => BackendErrorKind::Terminated, - } - } -} - -impl fmt::Display for BackendError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Descriptor(m) => write!(f, "descriptor error: {m}"), - Self::Denied(m) => write!(f, "attachment denied: {m}"), - Self::Unavailable(m) => write!(f, "boundary unavailable: {m}"), - Self::Unsupported(m) => write!(f, "operation unsupported: {m}"), - Self::Attach(m) => write!(f, "attachment failed: {m}"), - Self::Confirm(m) => write!(f, "confirmation failed: {m}"), - Self::Process(m) => write!(f, "process error: {m}"), - Self::Terminated(m) => write!(f, "boundary terminated: {m}"), - } - } -} - -impl std::error::Error for BackendError {} - -/// Why an identity resolution failed. Resolution failure fails closed: the -/// mediation service denies and audits the connection; it never authorizes. -#[derive(Debug, Clone)] -pub enum ResolveError { - /// No process owns the connection (stale or unknown attribution). - NotFound, - /// Resolution attempted but could not produce trustworthy identity. - Failed(String), -} - -impl fmt::Display for ResolveError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NotFound => write!(f, "connection owner not found"), - Self::Failed(m) => write!(f, "identity resolution failed: {m}"), - } - } -} - -impl std::error::Error for ResolveError {} - // ============================================================================ // Descriptor and registry // ============================================================================ -/// The common isolation backend descriptor envelope. -/// -/// The compute driver supplies one for the selected isolation backend. The opaque -/// payload identifies an existing resource or carries the trusted prepared -/// inputs the backend needs to establish one during `attach`; its protection -/// and resource lifecycle remain owned by the compute driver. -#[derive(Debug, Clone)] -pub struct BackendDescriptor { - /// The backend the supervisor must instantiate. - pub backend_name: String, - /// Backend-specific attachment data. - pub payload: Vec, -} - -/// Exact non-root identity selected before the immutable workload is created. -/// -/// Validation and normalization run in [`ResolvedWorkloadIdentity::new`] and -/// again when a value is decoded from a serialized descriptor: deserialization -/// routes through the same constructor, so an invalid identity fails to decode -/// instead of bypassing the contract. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "RawResolvedWorkloadIdentity")] -pub struct ResolvedWorkloadIdentity { - /// Effective and real user ID used by sandbox and all workload children. - pub uid: u32, - /// Primary group ID used by sandbox and all workload children. - pub gid: u32, - /// Sorted, unique supplementary groups inherited unchanged by children. - pub supplementary_gids: Vec, - /// Driver-defined resolution source (`policy`, `template`, or `image`). - pub source: String, - /// Digest of the immutable image/rootfs/config used for resolution. - pub resource_digest: String, -} - -impl ResolvedWorkloadIdentity { - /// Validate and construct a final workload identity. - pub fn new( - uid: u32, - gid: u32, - mut supplementary_gids: Vec, - source: String, - resource_digest: String, - ) -> Result { - if uid == 0 || gid == 0 || supplementary_gids.contains(&0) { - return Err(BackendError::Descriptor( - "workload identity must not contain UID or GID zero".to_string(), - )); - } - if source.trim().is_empty() || resource_digest.trim().is_empty() { - return Err(BackendError::Descriptor( - "workload identity source and resource digest are required".to_string(), - )); - } - supplementary_gids.retain(|supplementary_gid| *supplementary_gid != gid); - supplementary_gids.sort_unstable(); - supplementary_gids.dedup(); - Ok(Self { - uid, - gid, - supplementary_gids, - source, - resource_digest, - }) - } -} - -/// Unvalidated wire shape of [`ResolvedWorkloadIdentity`], existing only so -/// serde decoding can route through [`ResolvedWorkloadIdentity::new`]. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawResolvedWorkloadIdentity { - uid: u32, - gid: u32, - supplementary_gids: Vec, - source: String, - resource_digest: String, -} - -impl TryFrom for ResolvedWorkloadIdentity { - type Error = BackendError; - - fn try_from(raw: RawResolvedWorkloadIdentity) -> Result { - Self::new( - raw.uid, - raw.gid, - raw.supplementary_gids, - raw.source, - raw.resource_digest, - ) - } -} - /// The trusted sandbox context, constructed by trusted common code after the /// control plane assigns the resource to the admitted sandbox. /// @@ -248,52 +60,6 @@ use crate::AgentSpec; // Lifecycle states // ============================================================================ -/// Commitment to provisioning facts validated by the trusted compute driver. -/// -/// The driver validates its native placement and enforcement requirements before -/// constructing this binding. The digest includes the runtime generation, so -/// confirmation cannot reuse evidence from another launch. It is a binding, -/// not independent proof that the backend enforces policy. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProvisioningEvidence { - pub generation: String, - pub evidence_digest: Sha256Digest, -} - -impl ProvisioningEvidence { - /// Bind already-validated native provisioning evidence to one generation. - pub fn from_enforcement_evidence( - generation: impl Into, - native_evidence: &[u8], - ) -> Result { - let generation = generation.into(); - if generation.is_empty() || native_evidence.is_empty() { - return Err(BackendError::Descriptor( - "provisioning 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, - evidence_digest: Sha256Digest::compute(&binding), - }) - } - - /// Reject evidence bound to a different launch. - pub fn validate(&self, expected_generation: &str) -> Result<(), BackendError> { - if !self.generation.is_empty() && self.generation == expected_generation { - Ok(()) - } else { - Err(BackendError::Confirm( - "provisioning evidence belongs to another generation".to_string(), - )) - } - } -} - /// Successful policy-enforcement confirmation for one admitted launch. /// /// The backend rejects unsupported policy before confirming. Mechanisms are @@ -481,68 +247,6 @@ pub struct BinaryIdentity { pub cmdline_paths: Vec, } -/// A SHA-256 digest, kept typed so the identity field is not coupled to its -/// textual encoding or forced to repeat the algorithm in its name. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct Sha256Digest([u8; 32]); - -impl TryFrom for Sha256Digest { - type Error = ResolveError; - - fn try_from(value: String) -> Result { - value.parse() - } -} - -impl From for String { - fn from(value: Sha256Digest) -> Self { - value.to_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] { - &self.0 - } -} - -impl fmt::Display for Sha256Digest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for byte in self.0 { - write!(f, "{byte:02x}")?; - } - Ok(()) - } -} - -impl FromStr for Sha256Digest { - type Err = ResolveError; - - fn from_str(value: &str) -> Result { - if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(ResolveError::Failed( - "SHA-256 digest must contain 64 hexadecimal characters".to_string(), - )); - } - let mut bytes = [0_u8; 32]; - for (index, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| { - ResolveError::Failed("SHA-256 digest contains non-hexadecimal data".to_string()) - })?; - } - Ok(Self(bytes)) - } -} - /// Immutable socket metadata supplied with a pending external TCP open. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct NetworkSocketMetadata { diff --git a/crates/openshell-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index f95c7bd6a2..b04994e27c 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -16,6 +16,7 @@ test-support = [] [dependencies] openshell-crypto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false, features = ["jwt"] } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["legacy-contract"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } async-trait = "0.1" bytes = { workspace = true } diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 724c9e1faa..d7721aedcc 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -16,18 +16,21 @@ use std::path::PathBuf; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use openshell_core::SandboxSessionId; use openshell_core::policy::{ FilesystemPolicy, LandlockCompatibility, LandlockPolicy, NetworkMode, NetworkPolicy, ProcessPolicy, ProxyPolicy, SandboxPolicy, }; +pub use openshell_driver_sdk::legacy_contract::{ + BoundaryConfig, BoundaryListener, GatewayVerificationKey, SandboxRuntimeDescriptor, + SandboxTlsClientConfig, SandboxTlsMaterial, SandboxTlsServerConfig, SandboxTransport, + generate_sandbox_tls_material, validate_resource_claims, +}; use openshell_isolation_interface::AgentSpec; use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ - BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, - BoundarySignal, ExecSpec, ExecutableIdentity, ProvisioningEvidence, ResolveError, ShellSpec, + BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, BoundarySignal, + ExecSpec, ExecutableIdentity, ResolveError, ShellSpec, }; -use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyUsagePurpose}; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -239,348 +242,28 @@ impl<'de> Deserialize<'de> for SupervisorInstanceId { #[error("supervisor instance ID must be a non-nil canonical lowercase UUID")] pub struct SupervisorInstanceIdError; -/// Backend-owned transport for a driver-provisioned runtime. The serialized -/// variants preserve existing runtime descriptors independently of operator -/// endpoint registration in core. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] -pub enum SandboxTransport { - Unix { - socket_path: PathBuf, - }, - Tcp { - /// Logical HTTP authority used for routing. - authority: String, - /// Concrete connection candidates pinned by the operator or driver. - addresses: Vec, - }, - Vsock { - guest_cid: u32, - port: u32, - }, - /// The supervisor accepts a connection from its sandbox runtime. TLS and - /// gRPC roles stay unchanged. - RuntimeInitiatedTcp { - authority: String, - listen_address: std::net::SocketAddr, - }, - RuntimeInitiatedUnix { - socket_path: PathBuf, - }, - RuntimeInitiatedVsock { - guest_cid: u32, - port: u32, - }, - /// The runtime dials a supervisor shared by several sandbox instances and - /// names this instance's opaque route handle before the pinned TLS - /// handshake. - /// Only a host that owns the shared listener can serve this transport. - RuntimeInitiatedRouted { - authority: String, - route: String, - }, -} - -impl SandboxTransport { - pub fn validate(&self) -> Result<(), &'static str> { - let valid = match self { - Self::Unix { socket_path } | Self::RuntimeInitiatedUnix { socket_path } => { - openshell_core::paths::is_posix_absolute(socket_path) - } - Self::Tcp { - authority, - addresses, - } => { - !authority.is_empty() - && !addresses.is_empty() - && addresses - .iter() - .all(|address| address.port() != 0 && !address.ip().is_unspecified()) - } - Self::RuntimeInitiatedTcp { - authority, - listen_address, - } => !authority.is_empty() && listen_address.port() != 0, - Self::Vsock { guest_cid, port } | Self::RuntimeInitiatedVsock { guest_cid, port } => { - *guest_cid >= 3 && *guest_cid != u32::MAX && *port != 0 && *port != u32::MAX - } - Self::RuntimeInitiatedRouted { authority, route } => { - !authority.is_empty() && crate::runtime_routed::validate_route_handle(route).is_ok() - } - }; - if valid { - Ok(()) - } else { - Err("invalid boundary control transport endpoint") - } - } -} - -impl From for SandboxTransport { - fn from(endpoint: openshell_core::isolation_registration::SandboxTransport) -> Self { - use openshell_core::isolation_registration::SandboxTransport as RegisteredEndpoint; - match endpoint { - RegisteredEndpoint::Unix { socket_path } => Self::Unix { socket_path }, - RegisteredEndpoint::Tcp { - authority, - addresses, - } => Self::Tcp { - authority, - addresses, - }, - RegisteredEndpoint::Vsock { guest_cid, port } => Self::Vsock { guest_cid, port }, +/// Convert an operator-registered endpoint into the transport a driver puts in +/// a runtime descriptor. +#[must_use] +pub fn transport_from_registration( + endpoint: openshell_core::isolation_registration::SandboxTransport, +) -> SandboxTransport { + use openshell_core::isolation_registration::SandboxTransport as RegisteredEndpoint; + match endpoint { + RegisteredEndpoint::Unix { socket_path } => SandboxTransport::Unix { socket_path }, + RegisteredEndpoint::Tcp { + authority, + addresses, + } => SandboxTransport::Tcp { + authority, + addresses, + }, + RegisteredEndpoint::Vsock { guest_cid, port } => { + SandboxTransport::Vsock { guest_cid, port } } } } -/// Supervisor-side, generation-pinned TLS server authentication. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SandboxTlsClientConfig { - pub server_name: String, - pub trust_anchor_pem: String, -} - -impl fmt::Debug for SandboxTlsClientConfig { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SandboxTlsClientConfig") - .field("server_name", &self.server_name) - .field("trust_anchor_pem", &"") - .finish() - } -} - -/// Sandbox-side TLS server files staged by the compute driver. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SandboxTlsServerConfig { - pub certificate_chain_path: PathBuf, - pub private_key_path: PathBuf, -} - -/// Fresh, server-only TLS material for one sandbox session. -#[derive(Clone)] -pub struct SandboxTlsMaterial { - pub server_name: String, - pub trust_anchor_pem: String, - pub certificate_chain_pem: String, - pub private_key_pem: String, -} - -impl fmt::Debug for SandboxTlsMaterial { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SandboxTlsMaterial") - .field("server_name", &self.server_name) - .field("trust_anchor_pem", &"") - .field("certificate_chain_pem", &"") - .field("private_key_pem", &"") - .finish() - } -} - -/// Generate a generation-pinned TLS server identity for the Sandbox Protocol. -/// -/// The CA private key is local to this function and is discarded after the -/// server leaf is signed. The JWT, rather than certificate time, determines -/// caller authorization and renewal. -pub fn generate_sandbox_tls_material( - session_id: SandboxSessionId, -) -> Result { - let server_name = format!("sandbox.{session_id}.openshell.internal"); - let ca_key = openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519) - .map_err(|error| BackendError::Descriptor(format!("generate sandbox CA key: {error}")))?; - let mut ca_params = CertificateParams::default(); - ca_params.not_before = rcgen::date_time_ymd(1975, 1, 1); - ca_params.not_after = rcgen::date_time_ymd(4096, 1, 1); - ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params - .distinguished_name - .push(DnType::CommonName, "OpenShell sandbox session CA"); - ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; - let ca = openshell_crypto::pki::self_signed(ca_params, &ca_key).map_err(|error| { - BackendError::Descriptor(format!("generate sandbox CA certificate: {error}")) - })?; - - let sandbox_key = - openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519).map_err(|error| { - BackendError::Descriptor(format!("generate sandbox TLS server key: {error}")) - })?; - let mut sandbox_params = CertificateParams::new(vec![server_name.clone()]) - .map_err(|error| BackendError::Descriptor(format!("build sandbox certificate: {error}")))?; - sandbox_params.not_before = rcgen::date_time_ymd(1975, 1, 1); - sandbox_params.not_after = rcgen::date_time_ymd(4096, 1, 1); - sandbox_params - .distinguished_name - .push(DnType::CommonName, "OpenShell sandbox runtime"); - sandbox_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; - let sandbox = openshell_crypto::pki::signed_by(sandbox_params, &sandbox_key, &ca, &ca_key) - .map_err(|error| { - BackendError::Descriptor(format!("sign sandbox TLS server certificate: {error}")) - })?; - - Ok(SandboxTlsMaterial { - server_name, - trust_anchor_pem: ca.pem(), - certificate_chain_pem: sandbox.pem(), - private_key_pem: sandbox_key - .serialize_pem() - .map_err(|error| BackendError::Descriptor(format!("export sandbox key: {error}")))?, - }) -} - -/// Boundary-side listener provisioned by a compute driver. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] -pub enum BoundaryListener { - /// TLS over a private Unix socket shared with the host supervisor. - Unix { - socket_path: PathBuf, - tls: SandboxTlsServerConfig, - }, - /// TLS over TCP. An unspecified IP is valid for the sandbox bind. - TlsTcp { - address: std::net::SocketAddr, - tls: SandboxTlsServerConfig, - }, - /// TLS over guest `AF_VSOCK`. - Vsock { - control_port: u32, - tls: SandboxTlsServerConfig, - }, - /// The boundary dials the supervisor's listener and serves TLS and gRPC - /// over that outbound connection. Only driver-resolved addresses are used. - RuntimeInitiatedTlsTcp { - /// Stable logical Service authority, for diagnostics. - authority: String, - /// Driver-resolved supervisor endpoints, tried in order. - addresses: Vec, - tls: SandboxTlsServerConfig, - /// Route handle sent before TLS to a supervisor shared by several - /// sandbox instances. Absent for a dedicated supervisor. - #[serde(default, skip_serializing_if = "Option::is_none")] - route: Option, - }, - /// Dial the supervisor over the driver's protected shared Unix socket. - RuntimeInitiatedUnix { - socket_path: PathBuf, - tls: SandboxTlsServerConfig, - }, - /// Dial the host supervisor through the VM's vsock device. - RuntimeInitiatedVsock { - host_port: u32, - tls: SandboxTlsServerConfig, - }, -} - -/// Public verification key staged in the sandbox's immutable auth bundle. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GatewayVerificationKey { - pub key_id: String, - pub public_key_pem: String, -} - -/// Protected runtime descriptor consumed by `openshell-supervisor`. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SandboxRuntimeDescriptor { - /// Stable identity of the boundary, normally the sandbox ID. - pub boundary_id: String, - /// Immutable driver-owned workload generation. - pub generation: String, - /// Fresh gateway-issued identity for this exact launch. - pub session_id: SandboxSessionId, - /// Immutable numeric identity already applied to the sandbox workload. - pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity, - /// Driver-provisioned byte-stream endpoint. - pub transport: SandboxTransport, - /// Per-generation pinned TLS server identity. - pub tls: SandboxTlsClientConfig, - /// Trusted dial target for well-known host-gateway aliases, when the - /// network supervisor cannot use the boundary's resolver view. - #[serde(default)] - pub host_gateway_ip: Option, - /// Driver-specific immutable resource coordinates bound at attach (for - /// example pod UID, VM generation, or container ID). - #[serde(default)] - pub resource_claims: std::collections::BTreeMap, - /// Backend-neutral projection of the validated outer network fence. - pub provisioning_evidence: ProvisioningEvidence, -} - -impl fmt::Debug for SandboxRuntimeDescriptor { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SandboxRuntimeDescriptor") - .field("boundary_id", &self.boundary_id) - .field("generation", &self.generation) - .field("session_id", &self.session_id) - .field("transport", &self.transport) - .field("tls", &self.tls) - .field("host_gateway_ip", &self.host_gateway_ip) - .field("resource_claims", &self.resource_claims) - .field("provisioning_evidence", &self.provisioning_evidence) - .finish() - } -} - -impl SandboxRuntimeDescriptor { - /// Encode this runtime configuration for the `openshell-sandbox` backend. - pub fn backend_descriptor(&self) -> Result { - let payload = serde_json::to_vec(self).map_err(|error| { - BackendError::Descriptor(format!("encode runtime descriptor: {error}")) - })?; - Ok(BackendDescriptor { - backend_name: crate::BACKEND_NAME.to_string(), - payload, - }) - } -} - -/// Protected bootstrap configuration consumed by `openshell-sandbox`. -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BoundaryConfig { - /// Stable identity expected in every authenticated request. - pub boundary_id: String, - /// Immutable driver-owned workload generation. - pub generation: String, - /// Fresh gateway-issued identity for this exact launch. - pub session_id: SandboxSessionId, - /// Monotonic signed supervisor replacement within this generation. - pub session_rotation: openshell_core::jwt::SessionRotation, - /// Durable authorization epoch required in every Sandbox Protocol JWT. - pub auth_epoch: openshell_core::jwt::CredentialEpoch, - /// Gateway identity expected in Sandbox Protocol JWTs. - pub gateway_id: String, - /// Immutable current and staged-next gateway verification keys. - pub verification_keys: Vec, - /// Driver-provisioned listener. - pub listener: BoundaryListener, - /// Immutable coordinates the boundary requires from the control-side - /// runtime descriptor before accepting attachment. - #[serde(default)] - pub resource_claims: std::collections::BTreeMap, - /// Driver-provisioned, read-only runtime evidence for resource claims. - /// - /// Each entry maps a claim key to an absolute file whose trimmed contents - /// must equal the corresponding value in `resource_claims` before the - /// boundary opens its listener. Kubernetes uses this to bind a one-use - /// bootstrap bundle to the admitted workload Pod UID exposed by the - /// Downward API. Other drivers may leave the map empty. - #[serde(default)] - 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, - /// Backend-neutral projection of the validated outer network fence. - pub provisioning_evidence: ProvisioningEvidence, - /// Driver-resolved environment exposed only to workload processes. - #[serde(default)] - pub child_env: std::collections::HashMap, -} - /// Default time a runtime keeps a workload frozen, after losing its /// supervisor, for an authenticated supervisor to recover it. pub const DEFAULT_SUPERVISOR_RECOVERY_TIMEOUT: Duration = Duration::from_secs(30); @@ -600,65 +283,6 @@ pub fn supervisor_recovery_timeout(seconds: u32) -> Option { .then(|| Duration::from_secs(u64::from(seconds))) } -impl fmt::Debug for BoundaryConfig { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("BoundaryConfig") - .field("boundary_id", &self.boundary_id) - .field("generation", &self.generation) - .field("session_id", &self.session_id) - .field("session_rotation", &self.session_rotation) - .field("auth_epoch", &self.auth_epoch) - .field("gateway_id", &self.gateway_id) - .field( - "verification_key_ids", - &self - .verification_keys - .iter() - .map(|key| key.key_id.as_str()) - .collect::>(), - ) - .field("listener", &self.listener) - .field("resource_claims", &self.resource_claims) - .field("resource_claim_files", &self.resource_claim_files) - .field("workload_identity", &self.workload_identity) - .field("provisioning_evidence", &self.provisioning_evidence) - .field("child_env_keys", &self.child_env.keys().collect::>()) - .finish() - } -} - -impl BoundaryConfig { - /// Serialize the protected driver-owned boundary configuration. - pub fn encode(&self) -> Result, BackendError> { - serde_json::to_vec(self) - .map_err(|error| BackendError::Descriptor(format!("encode boundary config: {error}"))) - } -} - -/// Validate driver-specific immutable coordinates before a boundary binds them. -/// -/// Claim values are opaque to the common protocol, but empty or -/// whitespace-bearing identifiers cannot safely distinguish runtime objects. -pub fn validate_resource_claims( - claims: &std::collections::BTreeMap, -) -> Result<(), BackendError> { - for (key, value) in claims { - if key.is_empty() || key.chars().any(char::is_whitespace) { - return Err(BackendError::Descriptor( - "boundary resource-claim keys must be non-empty and contain no whitespace" - .to_string(), - )); - } - if value.is_empty() || value.chars().any(char::is_whitespace) { - return Err(BackendError::Descriptor(format!( - "boundary resource claim {key:?} must be non-empty and contain no whitespace" - ))); - } - } - Ok(()) -} - pub async fn write_stream_frame( writer: &mut (impl AsyncWrite + Unpin), channel: u8, @@ -1574,6 +1198,7 @@ pub enum FrameError { #[cfg(test)] mod tests { use super::*; + use openshell_core::SandboxSessionId; fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence { NativeLinuxSandboxAuditEvidence { diff --git a/crates/openshell-sandbox-backend/src/delegated.rs b/crates/openshell-sandbox-backend/src/delegated.rs index 96e2047ecb..b6e3950c13 100644 --- a/crates/openshell-sandbox-backend/src/delegated.rs +++ b/crates/openshell-sandbox-backend/src/delegated.rs @@ -43,7 +43,7 @@ impl DelegatedLaunch { generation: self.generation, session_id: self.session_id, workload_identity: self.workload_identity, - transport: transport.into(), + transport: crate::boundary_protocol::transport_from_registration(transport), tls: self.tls, host_gateway_ip: self.host_gateway_ip, resource_claims: self.resource_claims, diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 3df3f90ef7..eb16723e36 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -31,26 +31,10 @@ pub use runtime::{ }; pub use runtime::{BoundaryTransport, BoundaryTransportConnector, validate_runtime_coordinates}; -/// Stable isolation backend name implemented by `openshell-sandbox`. -pub const BACKEND_NAME: &str = openshell_core::isolation_registration::BUILTIN_BACKEND_NAME; - -/// Resource claim set by compute drivers when the workload requests GPU access. -pub const GPU_RESOURCE_CLAIM: &str = "openshell.gpu"; - -/// Resource claim set when the runtime may retain supplementary groups in -/// addition to the image-derived workload identity. -pub const ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM: &str = - "openshell.identity.allow_extra_supplementary_groups"; - -/// Memory-backed parent used for supervisor CA material. -pub const SUPERVISOR_CA_RUNTIME_ROOT: &str = "/run/openshell-supervisor-ca"; - -/// Workload-visible directory for the supervisor's public HTTPS interception -/// certificate and combined trust bundle. -/// -/// Keeping the material below the mount root lets runtimes that cannot assign -/// tmpfs ownership create this child as the unprivileged sandbox identity. -pub const SUPERVISOR_CA_RUNTIME_DIR: &str = "/run/openshell-supervisor-ca/material"; +pub use openshell_driver_sdk::legacy_contract::{ + ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM, BUILTIN_BACKEND_NAME as BACKEND_NAME, + GPU_RESOURCE_CLAIM, SUPERVISOR_CA_RUNTIME_DIR, SUPERVISOR_CA_RUNTIME_ROOT, +}; /// Generated gRPC transport envelope for the `OpenShell` Sandbox Protocol. pub mod proto { diff --git a/crates/openshell-sandbox-backend/src/runtime_routed.rs b/crates/openshell-sandbox-backend/src/runtime_routed.rs index 6a9822ae04..d3b0bee8af 100644 --- a/crates/openshell-sandbox-backend/src/runtime_routed.rs +++ b/crates/openshell-sandbox-backend/src/runtime_routed.rs @@ -46,8 +46,9 @@ pub const ROUTE_PREFACE_VERSION: u8 = 1; /// [`RouteReply`] before the TLS handshake. pub const ROUTE_PREFACE_VERSION_V2: u8 = 2; /// Longest accepted route handle, in bytes. -pub const MAX_ROUTE_HANDLE_LEN: usize = 64; -const MIN_ROUTE_HANDLE_LEN: usize = 16; +pub use openshell_driver_sdk::legacy_contract::{ + MAX_ROUTE_HANDLE_LEN, MIN_ROUTE_HANDLE_LEN, validate_route_handle, +}; /// Status bytes of a version 2 reply. An accept and a redirect are followed /// by a length byte and a runtime listener address as ASCII `ip:port`: the /// accepting member's own (empty while it knows no directory) or the owner's. @@ -66,23 +67,6 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); /// Authenticated connections queued for a sandbox instance but not yet claimed. const MAX_QUEUED_CONNECTIONS: usize = 2; -/// Validate an opaque route handle: 16 to 64 lowercase ASCII letters, digits, -/// or hyphens. -pub fn validate_route_handle(handle: &str) -> Result<(), BackendError> { - let valid = (MIN_ROUTE_HANDLE_LEN..=MAX_ROUTE_HANDLE_LEN).contains(&handle.len()) - && handle - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); - if valid { - Ok(()) - } else { - Err(BackendError::Descriptor( - "runtime route handle must be 16 to 64 lowercase letters, digits, or hyphens" - .to_string(), - )) - } -} - /// Generate a fresh opaque route handle for one runtime generation. #[must_use] pub fn new_route_handle() -> String { diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 90d5d26391..be351699c9 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2184,6 +2184,7 @@ dependencies = [ "prost", "prost-types", "protoc-bin-vendored", + "rcgen", "rustix 1.1.5", "rustls", "serde", @@ -2218,6 +2219,7 @@ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ "openshell-core", + "openshell-driver-sdk", "serde", "serde_json", "sha2 0.10.9", @@ -2246,6 +2248,7 @@ dependencies = [ "libc", "openshell-core", "openshell-crypto", + "openshell-driver-sdk", "openshell-isolation-interface", "prost", "prost-types",