From 7dc487be5d241dd1f192013276898e7c4772ef29 Mon Sep 17 00:00:00 2001 From: Shiju Date: Wed, 30 Sep 2026 22:26:01 +0530 Subject: [PATCH 1/4] fix(vm): validate launch credentials before preparation Negotiate a launch-authentication requirement and reject incomplete gateway configuration before driver validation, archive consumption or persistence. Validate replacement VM credentials before stopping active compute and prefer explicit signer configuration over local discovery. Closes #3949. Part of #3955. Signed-off-by: Shiju --- .../openshell-core/src/extension_protocol.rs | 11 +- crates/openshell-driver-vm/src/driver.rs | 274 +++++++++++++++--- .../src/auth/launch_signing.rs | 201 +++++++++++++ crates/openshell-server/src/auth/mod.rs | 1 + crates/openshell-server/src/cli.rs | 67 ++++- crates/openshell-server/src/compute/mod.rs | 132 ++++++++- crates/openshell-server/src/defaults.rs | 6 +- crates/openshell-server/src/grpc/sandbox.rs | 113 ++++++++ crates/openshell-server/src/lib.rs | 53 +--- crates/openshell-server/src/test_support.rs | 7 + docs/how-it-works/gateways/configuration.mdx | 6 + 11 files changed, 769 insertions(+), 102 deletions(-) create mode 100644 crates/openshell-server/src/auth/launch_signing.rs diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index 9b5a8f2a97..fa90c9cde8 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -12,6 +12,11 @@ use crate::proto::extension::v1::{PeerMetadata, ProtocolVersion}; pub const PROTOCOL_MAJOR: u32 = 1; pub const PROTOCOL_MINOR: u32 = 0; +/// Compute drivers require this capability when every launch needs a +/// gateway-minted [`crate::jwt::SandboxLaunchAuthentication`] bundle. The +/// gateway checks its configured signer before accepting sandbox creation. +pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication"; + const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128; const MAX_CAPABILITY_BYTES: usize = 128; @@ -103,6 +108,10 @@ pub enum NegotiationError { #[must_use] pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata { let contract = family.contract_capability(); + let mut supported_capabilities = vec![contract.clone()]; + if family == ExtensionFamily::Compute { + supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string()); + } PeerMetadata { protocol_version: Some(ProtocolVersion { major: PROTOCOL_MAJOR, @@ -110,7 +119,7 @@ pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata { }), implementation_name: "openshell/gateway".to_string(), implementation_version: crate::VERSION.to_string(), - supported_capabilities: vec![contract.clone()], + supported_capabilities, required_capabilities: vec![contract], } } diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 58ad21892e..cbe5bbd965 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1040,6 +1040,15 @@ impl VmDriver { #[must_use] pub fn capabilities(&self) -> GetCapabilitiesResponse { + let mut extension = openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "openshell/vm", + openshell_core::VERSION, + [], + ); + extension + .required_capabilities + .push(openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.to_string()); GetCapabilitiesResponse { resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { allow_driver_config: self.config.allow_driver_config, @@ -1070,12 +1079,7 @@ impl VmDriver { .to_string_lossy() .into_owned(), rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(), - extension: Some(openshell_core::extension_protocol::extension_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, - "openshell/vm", - openshell_core::VERSION, - [], - )), + extension: Some(extension), } } @@ -1103,6 +1107,12 @@ impl VmDriver { #[allow(clippy::result_large_err)] pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result { self.validate_sandbox(sandbox)?; + decode_launch_authentication( + sandbox + .spec + .as_ref() + .map_or(&[], |spec| spec.launch_authentication.as_slice()), + )?; info!( sandbox_id = %sandbox.id, sandbox_name = %sandbox.name, @@ -1293,6 +1303,14 @@ impl VmDriver { overlay_preparation: OverlayPreparation, ) -> Result<(), Status> { self.ensure_provisioning_active(&sandbox.id).await?; + // Launch credentials are independent of the image. Validate them before + // resolving registry references, preparing disks, or publishing progress. + let launch_authentication = decode_launch_authentication( + sandbox + .spec + .as_ref() + .map_or(&[], |spec| spec.launch_authentication.as_slice()), + )?; let is_gpu = sandbox .spec .as_ref() @@ -1372,29 +1390,6 @@ impl VmDriver { ))); } }; - let launch_authentication = sandbox - .spec - .as_ref() - .filter(|spec| !spec.launch_authentication.is_empty()) - .ok_or_else(|| { - Status::failed_precondition("VM sandbox launch authentication is required") - }) - .and_then(|spec| { - serde_json::from_slice::( - &spec.launch_authentication, - ) - .map_err(|error| { - Status::failed_precondition(format!( - "decode VM sandbox launch authentication: {error}" - )) - }) - })?; - launch_authentication.validate().map_err(|error| { - Status::failed_precondition(format!( - "validate VM sandbox launch authentication: {error}" - )) - })?; - self.publish_platform_event( sandbox.id.clone(), platform_event( @@ -1908,6 +1903,10 @@ impl VmDriver { return Ok(()); } } + // An invalid replacement must not stop the active VM or remove the + // previous generation's credentials. Preserve the empty idempotent + // replay above, which does not request a new launch. + decode_launch_authentication(&launch_authentication)?; let mut sandbox = read_sandbox_request(&state_dir.join(SANDBOX_REQUEST_FILE)) .await .map_err(|error| { @@ -1935,17 +1934,6 @@ impl VmDriver { ) .await .map_err(|error| Status::internal(format!("persist VM start generation: {error}")))?; - let authentication = serde_json::from_slice::< - openshell_core::jwt::SandboxLaunchAuthentication, - >(&launch_authentication) - .map_err(|error| { - Status::failed_precondition(format!("decode VM sandbox launch authentication: {error}")) - })?; - authentication.validate().map_err(|error| { - Status::failed_precondition(format!( - "validate VM sandbox launch authentication: {error}" - )) - })?; let spec = sandbox .spec .as_mut() @@ -4550,6 +4538,29 @@ fn check_gpu_privileges() -> Result<(), String> { // `tonic::Status` is ~176 bytes; it's the standard error type across the // gRPC API surface, so boxing here would diverge from every other handler. +#[allow(clippy::result_large_err)] +fn decode_launch_authentication( + encoded: &[u8], +) -> Result { + if encoded.is_empty() { + return Err(Status::failed_precondition( + "VM sandbox launch authentication is required; configure the gateway's \ + [openshell.gateway.gateway_jwt] signing bundle. Listener TLS is separate", + )); + } + // Serde errors may quote an unexpected field or value from the secret + // bundle. Return fixed diagnostics rather than forwarding those details. + let authentication = + serde_json::from_slice::(encoded) + .map_err(|_| { + Status::failed_precondition("VM sandbox launch authentication is malformed") + })?; + authentication.validate().map_err(|_| { + Status::failed_precondition("VM sandbox launch authentication has invalid fields") + })?; + Ok(authentication) +} + #[allow(clippy::result_large_err)] fn validate_vm_sandbox(sandbox: &Sandbox, gpu_enabled: bool) -> Result<(), Status> { validate_sandbox_id(&sandbox.id)?; @@ -7389,6 +7400,7 @@ mod tests { id: "sb-spawned-trace".to_string(), name: "spawned-trace".to_string(), spec: Some(SandboxSpec { + launch_authentication: test_launch_authentication("spawned-trace").0, template: Some(SandboxTemplate { image: "invalid image reference".to_string(), ..Default::default() @@ -8855,6 +8867,78 @@ mod tests { task.abort(); } + #[tokio::test] + async fn malformed_start_preserves_active_and_stopped_generation_material() { + for active in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = directory.path().to_path_buf(); + driver.config.default_image = "test/image:latest".to_string(); + let sandbox = Sandbox { + id: "sb-auth-start".to_string(), + name: "auth-start".to_string(), + spec: Some(SandboxSpec { + launch_authentication: test_launch_authentication("existing").0, + ..Default::default() + }), + ..Default::default() + }; + let state_dir = directory.path().join("sandbox"); + create_private_dir_all(&state_dir).await.unwrap(); + write_sandbox_request(&state_dir, &sandbox).await.unwrap(); + // An empty marker on the stopped fixture avoids invoking debugfs if + // the guard regresses, so the test reaches the destructive host cleanup. + let generation = if active { "g0000000000000001" } else { "" }; + std::fs::write(state_dir.join(HOST_BOUNDARY_GENERATION_FILE), generation).unwrap(); + std::fs::write( + state_dir.join(HOST_AUTH_BUNDLE_FILE), + b"retained authentication", + ) + .unwrap(); + let task = active.then(|| tokio::spawn(std::future::pending())); + driver.registry.lock().await.insert( + sandbox.id.clone(), + SandboxRecord { + snapshot: sandbox.clone(), + state_dir: state_dir.clone(), + process: None, + provisioning_task: task, + gpu_bdf: None, + deleting: false, + }, + ); + let error = driver + .start_sandbox( + &sandbox.id, + &sandbox.name, + "g0000000000000001", + br#"{"secret-marker-that-must-not-be-logged":true}"#.to_vec(), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), Code::FailedPrecondition); + assert_eq!( + error.message(), + "VM sandbox launch authentication is malformed" + ); + assert_eq!( + std::fs::read_to_string(state_dir.join(HOST_BOUNDARY_GENERATION_FILE)).unwrap(), + generation + ); + assert_eq!( + std::fs::read(state_dir.join(HOST_AUTH_BUNDLE_FILE)).unwrap(), + b"retained authentication" + ); + let record = driver.registry.lock().await.remove(&sandbox.id).unwrap(); + assert_eq!(record.snapshot, sandbox); + assert_eq!(record.provisioning_task.is_some(), active); + if let Some(task) = record.provisioning_task { + assert!(!task.is_finished()); + task.abort(); + } + } + } + fn test_launch_authentication(label: &str) -> (Vec, openshell_core::SandboxSessionId) { use openshell_core::jwt::{ SandboxLaunchAuthentication, SecretJwt, SessionVerificationKey, SupervisorAuthBundle, @@ -8984,6 +9068,111 @@ mod tests { }; assert_eq!(driver.capabilities().default_image, "openshell/sandbox:dev"); + let gateway = openshell_core::extension_protocol::gateway_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + ); + let negotiated = openshell_core::extension_protocol::negotiate( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "vm", + &gateway, + driver.capabilities().extension, + ) + .unwrap(); + assert!(negotiated.required_capabilities.iter().any(|capability| { + capability == openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION + })); + } + + #[tokio::test] + async fn launch_authentication_is_checked_before_create_side_effects() { + let directory = tempfile::tempdir().unwrap(); + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = directory.path().to_path_buf(); + driver.config.default_image = "invalid image reference".to_string(); + let mut events = driver.events.subscribe(); + let mut invalid_fields: serde_json::Value = + serde_json::from_slice(&test_launch_authentication("invalid-fields").0).unwrap(); + invalid_fields["verification_keys"] = serde_json::json!([]); + let malformed = br#"{"secret-marker-that-must-not-be-logged":true}"#.to_vec(); + + for (authentication, diagnostic) in [ + (Vec::new(), "is required"), + (malformed, "is malformed"), + ( + serde_json::to_vec(&invalid_fields).unwrap(), + "has invalid fields", + ), + ] { + let sandbox = Sandbox { + id: "sb-auth-preflight".to_string(), + name: "auth-preflight".to_string(), + spec: Some(SandboxSpec { + launch_authentication: authentication, + ..Default::default() + }), + ..Default::default() + }; + let error = driver + .create_sandbox(&sandbox) + .await + .expect_err("invalid launch material must fail synchronously"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains(diagnostic), "{error}"); + assert!(!error.message().contains("secret-marker")); + assert!(driver.registry.lock().await.is_empty()); + assert!(directory.path().read_dir().unwrap().next().is_none()); + assert!(matches!( + events.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + } + } + + #[tokio::test] + async fn provisioning_rechecks_launch_authentication_before_resolving_images() { + let directory = tempfile::tempdir().unwrap(); + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = directory.path().to_path_buf(); + driver.config.bootstrap_image = "invalid bootstrap image reference".to_string(); + let sandbox = Sandbox { + id: "sb-auth-recovery".to_string(), + name: "auth-recovery".to_string(), + spec: Some(SandboxSpec::default()), + ..Default::default() + }; + let state_dir = directory.path().join("sandbox"); + driver.registry.lock().await.insert( + sandbox.id.clone(), + SandboxRecord { + snapshot: sandbox.clone(), + state_dir: state_dir.clone(), + process: None, + provisioning_task: None, + gpu_bdf: None, + deleting: false, + }, + ); + let mut events = driver.events.subscribe(); + let error = driver + .provision_sandbox_inner( + sandbox, + "invalid image reference".to_string(), + state_dir, + None, + OverlayPreparation::PreserveExisting, + ) + .await + .unwrap_err(); + assert!( + error + .message() + .contains("VM sandbox launch authentication is required") + ); + assert!(matches!( + events.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + assert!(directory.path().read_dir().unwrap().next().is_none()); } #[test] @@ -9951,7 +10140,10 @@ mod tests { .create_sandbox(&Sandbox { id: "sandbox-123".to_string(), name: "sandbox-123".to_string(), - spec: Some(SandboxSpec::default()), + spec: Some(SandboxSpec { + launch_authentication: test_launch_authentication("duplicate").0, + ..Default::default() + }), ..Default::default() }) .await diff --git a/crates/openshell-server/src/auth/launch_signing.rs b/crates/openshell-server/src/auth/launch_signing.rs new file mode 100644 index 0000000000..9a0bd9b3f1 --- /dev/null +++ b/crates/openshell-server/src/auth/launch_signing.rs @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Load and validate sandbox launch signing before gateway startup connects drivers. + +use std::sync::Arc; + +use openshell_core::{Error, GatewayJwtConfig}; + +use super::sandbox_jwt::{ExtensionJwtIssuer, SandboxSessionJwtAuthority}; + +pub(crate) struct LaunchSigningAuthorities { + pub extension: Arc, + pub sandbox_session: Arc, +} + +pub(crate) fn load(config: &GatewayJwtConfig) -> openshell_core::Result { + let signing_pem = std::fs::read(&config.signing_key_path).map_err(|error| { + Error::config(format!( + "cannot read sandbox launch-signing private key from {}: {error}", + config.signing_key_path.display() + )) + })?; + let public_pem = std::fs::read(&config.public_key_path).map_err(|error| { + Error::config(format!( + "cannot read sandbox launch-signing public key from {}: {error}", + config.public_key_path.display() + )) + })?; + let kid = std::fs::read_to_string(&config.kid_path) + .map_err(|error| { + Error::config(format!( + "cannot read sandbox launch-signing key ID from {}: {error}", + config.kid_path.display() + )) + })? + .trim() + .to_string(); + if kid.is_empty() { + return Err(Error::config(format!( + "sandbox launch-signing key ID file {} is empty", + config.kid_path.display() + ))); + } + let extension = ExtensionJwtIssuer::from_pem( + &signing_pem, + &public_pem, + kid.clone(), + &config.gateway_id, + config.token_ttl(), + ) + .map_err(|_| { + Error::config( + "invalid sandbox launch-signing bundle: expected Ed25519 private and public keys", + ) + })?; + let sandbox_session = SandboxSessionJwtAuthority::from_pem( + &signing_pem, + &public_pem, + kid, + &config.gateway_id, + config.sandbox_token_ttl(), + ) + .map_err(|error| { + // This constructor maps core SessionJwtError variants to fixed text; + // preserve their actionable field and lifetime diagnostics. + Error::config(format!("invalid sandbox launch-signing bundle: {error}")) + })?; + + // Parsing two keys does not establish that they are a pair. Sign and verify + // a local probe so mismatched keys fail before a driver prepares an image. + // The probe is never persisted or sent to a driver or supervisor. + let identity = super::sandbox_session::PersistedSandboxIdentity::new() + .map_err(|_| Error::config("cannot initialize sandbox launch-signing validation"))?; + let probe = sandbox_session + .mint_persisted_launch("launch-signing-preflight", &identity) + .map_err(|_| Error::config("sandbox launch-signing key cannot mint session credentials"))?; + sandbox_session + .verify_gateway_token(probe.supervisor.gateway_token.expose_secret()) + .map_err(|_| { + Error::config("sandbox launch-signing private and public keys do not match") + })?; + + Ok(LaunchSigningAuthorities { + extension: Arc::new(extension), + sandbox_session: Arc::new(sandbox_session), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_bootstrap::jwt::generate_jwt_key; + + fn bundle(directory: &std::path::Path) -> GatewayJwtConfig { + let material = generate_jwt_key().expect("generate signing material"); + std::fs::create_dir_all(directory).unwrap(); + let config = GatewayJwtConfig { + signing_key_path: directory.join("signing.pem"), + public_key_path: directory.join("public.pem"), + kid_path: directory.join("kid"), + gateway_id: "test-gateway".to_string(), + ttl_secs: None, + }; + std::fs::write(&config.signing_key_path, material.signing_key_pem).unwrap(); + std::fs::write(&config.public_key_path, material.public_key_pem).unwrap(); + std::fs::write(&config.kid_path, material.kid).unwrap(); + config + } + + fn failure(config: &GatewayJwtConfig) -> String { + match load(config) { + Ok(_) => panic!("invalid bundle must fail before driver startup"), + Err(error) => error.to_string(), + } + } + + #[test] + fn explicit_bundle_mints_and_verifies_launch_credentials() { + let directory = tempfile::tempdir().unwrap(); + let config = bundle(directory.path()); + assert!(load(&config).is_ok()); + } + + #[test] + fn discovered_bundle_mints_and_verifies_launch_credentials() { + let directory = tempfile::tempdir().unwrap(); + bundle(&directory.path().join("jwt")); + let config = crate::defaults::local_jwt_config(directory.path()) + .unwrap() + .unwrap(); + assert!(load(&config).is_ok()); + } + + #[test] + fn missing_signing_file_names_the_required_file() { + let directory = tempfile::tempdir().unwrap(); + let config = bundle(directory.path()); + std::fs::remove_file(&config.signing_key_path).unwrap(); + let error = failure(&config); + assert!(error.contains("cannot read sandbox launch-signing private key")); + assert!(error.contains("signing.pem")); + } + + #[test] + fn invalid_signing_metadata_keeps_specific_diagnostics() { + let directory = tempfile::tempdir().unwrap(); + let mut config = bundle(directory.path()); + config.ttl_secs = std::num::NonZeroU64::new(1); + assert!(failure(&config).contains("between 60 and 3600 seconds")); + config.ttl_secs = None; + config.gateway_id = "invalid gateway secret-marker".to_string(); + let error = failure(&config); + assert!(error.contains("gateway ID is invalid")); + assert!(!error.contains("secret-marker")); + config.gateway_id = "valid-gateway".to_string(); + std::fs::write(&config.kid_path, "invalid kid secret-marker").unwrap(); + let error = failure(&config); + assert!(error.contains("key ID is invalid")); + assert!(!error.contains("secret-marker")); + } + + #[test] + fn missing_public_key_and_key_id_name_the_required_file() { + let directory = tempfile::tempdir().unwrap(); + let config = bundle(directory.path()); + std::fs::remove_file(&config.public_key_path).unwrap(); + assert!(failure(&config).contains("cannot read sandbox launch-signing public key")); + let config = bundle(directory.path()); + std::fs::remove_file(&config.kid_path).unwrap(); + assert!(failure(&config).contains("cannot read sandbox launch-signing key ID")); + } + + #[test] + fn malformed_signing_material_is_not_in_diagnostics() { + let directory = tempfile::tempdir().unwrap(); + let config = bundle(directory.path()); + let marker = "secret-marker-that-must-not-be-logged"; + std::fs::write(&config.signing_key_path, marker).unwrap(); + let error = failure(&config); + assert!(error.contains("invalid sandbox launch-signing bundle")); + assert!(!error.contains(marker)); + } + + #[test] + fn empty_key_id_is_reported_before_driver_startup() { + let directory = tempfile::tempdir().unwrap(); + let config = bundle(directory.path()); + std::fs::write(&config.kid_path, " \n").unwrap(); + assert!(failure(&config).contains("is empty")); + } + + #[test] + fn mismatched_keys_are_rejected_before_driver_startup() { + let directory = tempfile::tempdir().unwrap(); + let config = bundle(directory.path()); + let other = generate_jwt_key().unwrap(); + std::fs::write(&config.public_key_path, other.public_key_pem).unwrap(); + assert!(failure(&config).contains("private and public keys do not match")); + } +} diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index 3994ee537b..4b1b44432c 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -16,6 +16,7 @@ pub mod extension_mint_limit; pub mod guard; mod http; pub mod identity; +pub(crate) mod launch_signing; pub mod method_authz; pub mod oidc; pub mod peer; diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 9000ab1324..5bed8cea72 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -366,7 +366,15 @@ fn prepare_server_config_with_drivers( args.disable_tls, ) .map_err(|error| miette::miette!("invalid gateway guest TLS configuration: {error}"))?; - let local_jwt = defaults::complete_local_jwt_config()?; + // Explicit signing configuration must not depend on an unrelated, partial + // local bundle left by a package-managed installation. + let explicit_jwt = file + .as_ref() + .and_then(|file| file.openshell.gateway.gateway_jwt.clone()); + let gateway_jwt = match explicit_jwt { + Some(jwt) => Some(jwt), + None => defaults::complete_local_jwt_config()?, + }; let bind = SocketAddr::new(args.bind_address, args.port); @@ -578,14 +586,7 @@ fn prepare_server_config_with_drivers( // package-managed starts also auto-detect the JWT bundle written next to // the generated TLS bundle so upgrades pick up sandbox auth without a // user-authored config file. - if let Some(jwt) = file - .as_ref() - .and_then(|f| f.openshell.gateway.gateway_jwt.clone()) - { - config.gateway_jwt = Some(jwt); - } else if let Some(jwt) = local_jwt { - config.gateway_jwt = Some(jwt); - } + config.gateway_jwt = gateway_jwt; Ok(ServerStartupConfig { config, @@ -3325,4 +3326,52 @@ mem_mib = "not-a-number" assert!(file.openshell.drivers.contains_key("docker")); assert!(file.openshell.drivers.contains_key("vm")); } + + #[test] + fn explicit_launch_signing_config_ignores_partial_local_bundle() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let directory = tempfile::tempdir().unwrap(); + let _state = EnvVarGuard::set("XDG_STATE_HOME", directory.path().to_str().unwrap()); + let _local = EnvVarGuard::set( + "OPENSHELL_LOCAL_TLS_DIR", + directory.path().to_str().unwrap(), + ); + std::fs::create_dir(directory.path().join("jwt")).unwrap(); + std::fs::write( + directory.path().join("jwt/signing.pem"), + "incomplete local bundle", + ) + .unwrap(); + let config_path = directory.path().join("gateway.toml"); + std::fs::write( + &config_path, + r#" +[openshell] +version = 2 +[openshell.gateway.gateway_jwt] +signing_key_path = "/explicit/signing.pem" +public_key_path = "/explicit/public.pem" +kid_path = "/explicit/kid" +gateway_id = "explicit-gateway" +"#, + ) + .unwrap(); + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--config", + config_path.to_str().unwrap(), + "--db-url", + "sqlite::memory:", + "--compute-driver", + "podman", + "--disable-tls", + ]); + let prepared = super::prepare_server_config(&mut args, &matches).unwrap(); + assert_eq!( + prepared.config.gateway_jwt.unwrap().gateway_id, + "explicit-gateway" + ); + } } diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index da0ebffef0..cfb54dbfc9 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -999,6 +999,29 @@ impl ComputeRuntime { .await } + /// Check the selected driver's launch contract without contacting the driver. + pub(crate) fn validate_launch_signer_configured(&self, configured: bool) -> Result<(), Status> { + // AuthenticateSandbox handles driver-native bootstrap credentials. It + // does not declare whether launches require a gateway signing bundle. + let required = self + .driver_info + .negotiated_extension + .required_capabilities + .iter() + .any(|capability| { + capability == openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION + }); + if required && !configured { + return Err(Status::failed_precondition( + "the selected compute driver requires sandbox launch signing; configure \ + [openshell.gateway.gateway_jwt] or provide jwt/signing.pem, jwt/public.pem, \ + and jwt/kid under OPENSHELL_LOCAL_TLS_DIR (default: the OpenShell state \ + directory's tls/). Listener TLS does not configure sandbox launch signing", + )); + } + Ok(()) + } + pub async fn create_sandbox_authenticated( &self, sandbox: Sandbox, @@ -1033,6 +1056,13 @@ impl ComputeRuntime { lifecycle_guard: SandboxLifecycleGuard, global_guard: SandboxSyncGuard, ) -> Result { + // Defend the internal create path too, before consuming a staged archive + // or persisting the sandbox. The gRPC handler checks before driver validation. + self.validate_launch_signer_configured( + launch_authentication + .as_ref() + .is_some_and(|auth| !auth.is_empty()), + )?; self.validate_caller_driver_config( sandbox .spec @@ -7267,6 +7297,8 @@ mod tests { current_sandboxes: Vec, workspace_rpcs_unimplemented: bool, omit_protocol_metadata: bool, + requires_launch_authentication: bool, + create_calls: AtomicUsize, } #[tonic::async_trait] @@ -7303,12 +7335,19 @@ mod tests { rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, extension: (!self.omit_protocol_metadata).then(|| { - openshell_core::extension_protocol::extension_metadata( + let mut metadata = openshell_core::extension_protocol::extension_metadata( ExtensionFamily::Compute, "openshell/test-driver", "test", [], - ) + ); + if self.requires_launch_authentication { + metadata.required_capabilities.push( + openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION + .to_string(), + ); + } + metadata }), })) } @@ -7368,6 +7407,7 @@ mod tests { &self, _request: Request, ) -> Result, Status> { + self.create_calls.fetch_add(1, Ordering::SeqCst); Ok(tonic::Response::new(CreateSandboxResponse::default())) } @@ -14989,6 +15029,94 @@ mod tests { ); } + #[tokio::test] + async fn negotiated_launch_requirement_rejects_create_before_driver_or_store() { + let driver = Arc::new(TestDriver { + requires_launch_authentication: true, + ..Default::default() + }); + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let runtime = ComputeRuntime::from_driver( + "external-requires-launch".to_string(), + driver.clone(), + None, + store.clone(), + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + ) + .await + .unwrap(); + + for authentication in [None, Some(Vec::new())] { + let sandbox = sandbox_record( + "sb-missing-signer", + "missing-signer", + SandboxPhase::Provisioning, + ); + let error = runtime + .create_sandbox_authenticated(sandbox, None, authentication, false) + .await + .expect_err("the negotiated launch requirement must be checked before create"); + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("[openshell.gateway.gateway_jwt]")); + assert!(error.message().contains("OPENSHELL_LOCAL_TLS_DIR")); + assert!(error.message().contains("Listener TLS")); + assert!( + store + .get_message::("sb-missing-signer") + .await + .unwrap() + .is_none() + ); + assert_eq!(driver.create_calls.load(Ordering::SeqCst), 0); + } + + let sandbox = sandbox_record("sb-with-signer", "with-signer", SandboxPhase::Provisioning); + let identity = crate::auth::sandbox_session::PersistedSandboxIdentity::new().unwrap(); + let authentication = test_session_authority() + .mint_persisted_launch("sb-with-signer", &identity) + .unwrap(); + runtime + .create_sandbox_authenticated( + sandbox, + None, + Some(serde_json::to_vec(&authentication).unwrap()), + false, + ) + .await + .unwrap(); + assert_eq!(driver.create_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn external_driver_without_launch_requirement_can_create_without_signer() { + let driver = Arc::new(TestDriver::default()); + let runtime = ComputeRuntime::from_driver( + "external-other-auth".to_string(), + driver.clone(), + None, + Arc::new(Store::connect("sqlite::memory:").await.unwrap()), + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + ) + .await + .unwrap(); + runtime.validate_launch_signer_configured(false).unwrap(); + runtime + .create_sandbox( + sandbox_record("sb-other-auth", "other-auth", SandboxPhase::Provisioning), + None, + false, + ) + .await + .unwrap(); + assert_eq!(driver.create_calls.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn create_sandbox_returns_resource_version_one() { let runtime = test_runtime(Arc::new(TestDriver::default())).await; diff --git a/crates/openshell-server/src/defaults.rs b/crates/openshell-server/src/defaults.rs index 21e66b02bd..ca2cf556cd 100644 --- a/crates/openshell-server/src/defaults.rs +++ b/crates/openshell-server/src/defaults.rs @@ -95,7 +95,11 @@ pub fn complete_local_tls_paths() -> Result> { pub fn complete_local_jwt_config() -> Result> { let dir = default_local_tls_dir()?; - let paths = LocalJwtPaths::resolve(&dir); + local_jwt_config(&dir) +} + +pub(crate) fn local_jwt_config(dir: &Path) -> Result> { + let paths = LocalJwtPaths::resolve(dir); let present = paths.files().iter().filter(|path| path.is_file()).count(); match present { 0 => Ok(None), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 8e3aa58372..a95875fef6 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -629,6 +629,9 @@ async fn handle_create_sandbox_inner( ) .await?; + state + .compute + .validate_launch_signer_configured(state.sandbox_session_jwt_authority.is_some())?; state .compute .validate_sandbox_create(&sandbox) @@ -3926,6 +3929,116 @@ mod tests { use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{GpuResourceRequirements, SandboxServiceExposure, ServiceEndpoint}; + #[tokio::test] + async fn missing_launch_signer_precedes_driver_validation_and_preserves_staged_archive() { + let directory = tempfile::tempdir().unwrap(); + let mut state = test_server_state().await; + let mut metadata = openshell_core::extension_protocol::extension_metadata( + openshell_core::extension_protocol::ExtensionFamily::Compute, + "test/launch-authentication", + "test", + [], + ); + metadata + .required_capabilities + .push(openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.to_string()); + let admission = openshell_core::resource_admission::DriverAdmissionConfig { + allow_driver_config: true, + ..Default::default() + }; + let driver = Arc::new( + crate::test_support::FakeComputeDriver::new().with_capabilities( + openshell_core::proto::compute::v1::GetCapabilitiesResponse { + driver_name: "test".to_string(), + default_image: "test/image:latest".to_string(), + rootfs_tar_staging_dir: directory.path().to_string_lossy().into_owned(), + rootfs_tar_max_bytes: 1024, + resource_admission_policy: admission.acknowledgement(), + extension: Some(metadata), + ..Default::default() + }, + ), + ); + let compute = crate::compute::ComputeRuntime::from_driver( + "test".to_string(), + driver.clone(), + None, + state.store.clone(), + crate::sandbox_index::SandboxIndex::new(), + crate::sandbox_watch::SandboxWatchBus::new(), + crate::tracing_bus::TracingLogBus::new(), + Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), + ) + .await + .unwrap() + .with_admission_policy(admission) + .unwrap(); + Arc::get_mut(&mut state).unwrap().compute = compute; + let staging = state.compute.rootfs_tar_staging(); + let slot = staging + .begin("default", "dev-user", "rootfs.tar", 7) + .unwrap(); + std::fs::write(&slot.upload_path, b"archive").unwrap(); + let driver_config = Struct { + fields: [( + "test".to_string(), + Value { + kind: Some(Kind::StructValue(Struct { + fields: [( + crate::compute::rootfs_tar::STAGING_TOKEN_FIELD.to_string(), + Value { + kind: Some(Kind::StringValue(slot.token.clone())), + }, + )] + .into_iter() + .collect(), + })), + }, + )] + .into_iter() + .collect(), + }; + driver.clear_calls(); + let error = handle_create_sandbox( + &state, + authed_request(CreateSandboxRequest { + name: "missing-signer".to_string(), + spec: Some(SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(driver_config), + ..Default::default() + }), + ..Default::default() + }), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("sandbox launch signing")); + assert!( + driver.calls().is_empty(), + "driver validation must not precede signing preflight" + ); + assert_eq!( + staging.peek(&slot.token).unwrap(), + std::path::PathBuf::from(&slot.upload_path) + ); + assert_eq!(std::fs::read(&slot.upload_path).unwrap(), b"archive"); + assert!( + state + .store + .get_message_by_name::("default", "missing-signer") + .await + .unwrap() + .is_none() + ); + } + // ---- shell_escape ---- #[test] diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index d6d35f6ba3..7778ba117a 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -536,59 +536,16 @@ pub(crate) async fn run_server( // startup Describe calls can authenticate with gateway-caller tokens. let (extension_jwt_issuer, sandbox_session_jwt_authority) = if let Some(ref jwt) = config.gateway_jwt { - let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT signing key from {}: {e}", - jwt.signing_key_path.display() - )) - })?; - let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT public key from {}: {e}", - jwt.public_key_path.display() - )) - })?; - let kid = std::fs::read_to_string(&jwt.kid_path) - .map_err(|e| { - Error::config(format!( - "failed to read sandbox JWT kid from {}: {e}", - jwt.kid_path.display() - )) - })? - .trim() - .to_string(); - if kid.is_empty() { - return Err(Error::config(format!( - "sandbox JWT kid file {} is empty", - jwt.kid_path.display() - ))); - } - let issuer = Arc::new( - auth::sandbox_jwt::ExtensionJwtIssuer::from_pem( - &signing_pem, - &public_pem, - kid.clone(), - &jwt.gateway_id, - jwt.token_ttl(), - ) - .map_err(Error::config)?, - ); - let session_authority = Arc::new( - auth::sandbox_jwt::SandboxSessionJwtAuthority::from_pem( - &signing_pem, - &public_pem, - kid, - &jwt.gateway_id, - jwt.sandbox_token_ttl(), - ) - .map_err(Error::config)?, - ); + let authorities = auth::launch_signing::load(jwt)?; info!( gateway_id = %jwt.gateway_id, ttl_secs = jwt.ttl_secs.map(std::num::NonZeroU64::get), "gateway-minted sandbox JWT enabled" ); - (Some(issuer), Some(session_authority)) + ( + Some(authorities.extension), + Some(authorities.sandbox_session), + ) } else { (None, None) }; diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index dcbc2e1cf9..0cd4abf2d5 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -140,6 +140,13 @@ impl Default for FakeComputeDriver { } impl FakeComputeDriver { + /// Override the handshake response to exercise gateway capability admission. + #[must_use] + pub fn with_capabilities(self, capabilities: GetCapabilitiesResponse) -> Self { + self.with_state(|state| state.capabilities = capabilities); + self + } + #[must_use] pub fn new() -> Self { Self { diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 3b3c3aaaf0..4d8a3fb43b 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -274,6 +274,12 @@ The client-certificate handshake policy is derived and has no `require_client_au `[openshell.gateway.gateway_jwt] ttl_secs` controls generation-bound gateway-facing and Sandbox Protocol credentials minted for a sandbox session, plus typed extension JWTs. Omit it for non-expiring local sandbox session credentials: both session tokens carry `exp = 0`, supervisors skip periodic session-token renewal, and refresh responses omit their expiration timestamps. Typed extension JWTs retain a 900-second default when the field is omitted. Use omission only for local single-player Docker, Podman, or VM gateways. Explicit `0` is invalid. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway omits the field. +Listener TLS and sandbox launch signing use separate keys. A working HTTPS listener or mTLS client certificate does not supply the signing key needed to start a VM sandbox. Configure `signing_key_path`, `public_key_path`, `kid_path`, and `gateway_id` in `[openshell.gateway.gateway_jwt]`, as shown above. For a local installation, `openshell-gateway generate-certs` writes a signing bundle alongside the TLS bundle. Without explicit `gateway_jwt` configuration, the gateway discovers `jwt/signing.pem`, `jwt/public.pem`, and `jwt/kid` under `OPENSHELL_LOCAL_TLS_DIR`, or under the OpenShell state directory's `tls/` directory when that variable is unset. Explicit signing configuration takes precedence over local discovery. + +The gateway rejects partial or invalid signing bundles at startup, including private and public keys that do not match. When the selected driver requires launch signing and no bundle is configured, sandbox creation fails before the driver validates or prepares the sandbox. The error names the signing configuration and discovery location; it does not print keys or tokens. The VM driver also validates launch material before resolving images or creating sandbox state. + +External compute drivers declare this requirement by adding `openshell.compute.launch-authentication` to their extension metadata's `required_capabilities`. This requires a gateway that understands the launch-signing preflight and supplies `SandboxLaunchAuthentication` on creation. Drivers that use another launch mechanism can omit the requirement. The separate `supports_sandbox_authentication` capability describes the `AuthenticateSandbox` RPC and does not require launch signing. + `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. ## OCSF JSONL Output From 2f225e6d5b576fee904008f71ecd87a1d8e5995b Mon Sep 17 00:00:00 2001 From: Shiju Date: Wed, 30 Sep 2026 22:50:33 +0530 Subject: [PATCH 2/4] fix(vm): validate saved launch credentials before restore side effects Signed-off-by: Shiju --- crates/openshell-driver-vm/src/driver.rs | 128 +++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index cbe5bbd965..a3ab0abdf9 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -2226,6 +2226,17 @@ impl VmDriver { clear_stop_marker: bool, reconciliation_span: &tracing::Span, ) -> bool { + // Startup recovery bypasses create/start admission. Validate saved + // credentials before host preparation, extension hooks, or state changes. + if let Err(error) = decode_launch_authentication( + sandbox + .spec + .as_ref() + .map_or(&[], |spec| spec.launch_authentication.as_slice()), + ) { + warn!(sandbox_id = %sandbox.id, reason = %error.message(), "VM recovery denied by launch authentication"); + return false; + } if let Err(error) = self.validate_sandbox(&sandbox) { warn!(sandbox_id = %sandbox.id, reason = %error.message(), "VM recovery denied by admission"); return false; @@ -7450,6 +7461,7 @@ mod tests { id: format!("sb-restored-trace-{suffix}"), name: format!("restored-trace-{suffix}"), spec: Some(SandboxSpec { + launch_authentication: test_launch_authentication(suffix).0, template: Some(SandboxTemplate { image: "invalid image reference".to_string(), ..Default::default() @@ -9175,6 +9187,122 @@ mod tests { assert!(directory.path().read_dir().unwrap().next().is_none()); } + #[tokio::test] + async fn recovery_rejects_invalid_launch_authentication_before_side_effects() { + #[derive(Debug, Default)] + struct RestoreObserver { + calls: AtomicUsize, + } + + #[tonic::async_trait] + impl LifecycleExtension for RestoreObserver { + fn name(&self) -> &str { + "restore-observer" + } + + async fn before_restore(&self, _sandbox: &RestoreContext) -> LifecycleResult<()> { + self.calls.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + } + + for scan_at_startup in [true, false] { + for authentication in [ + Vec::new(), + br#"{"secret-marker-that-must-not-be-logged":true}"#.to_vec(), + ] { + let directory = tempfile::tempdir().unwrap(); + let observer = Arc::new(RestoreObserver::default()); + let mut driver = + test_driver_with_extensions(LifecycleExtensionRegistry::with(vec![ + observer.clone(), + ])); + driver.config.state_dir = directory.path().to_path_buf(); + driver.config.default_image = "invalid image reference".to_string(); + let sandbox = Sandbox { + id: "sb-auth-restore".to_string(), + name: "auth-restore".to_string(), + spec: Some(SandboxSpec { + launch_authentication: authentication, + ..Default::default() + }), + ..Default::default() + }; + let state_dir = sandbox_state_dir(directory.path(), &sandbox.id).unwrap(); + create_private_dir_all(&state_dir).await.unwrap(); + write_sandbox_request(&state_dir, &sandbox).await.unwrap(); + fs::write(state_dir.join("overlay.ext4"), b"retained overlay").unwrap(); + fs::write(state_dir.join(HOST_AUTH_BUNDLE_FILE), b"retained auth").unwrap(); + if !scan_at_startup { + fs::write(state_dir.join(SANDBOX_STOPPED_FILE), b"stopped\n").unwrap(); + } + let mut original_files = fs::read_dir(&state_dir) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + (entry.file_name(), fs::read(entry.path()).unwrap()) + }) + .collect::>(); + original_files.sort(); + let mut events = driver.events.subscribe(); + + let accepted = if scan_at_startup { + driver.restore_persisted_sandboxes().await; + false + } else { + driver + .restore_persisted_sandbox( + sandbox.clone(), + state_dir.clone(), + true, + &tracing::Span::current(), + ) + .await + }; + // If validation regresses, join the failed provisioning task so + // its later cleanup cannot race with the state assertions. + let task = driver + .registry + .lock() + .await + .get_mut(&sandbox.id) + .and_then(|record| record.provisioning_task.take()); + if let Some(task) = task { + task.await.unwrap(); + } + + assert!( + !accepted, + "invalid launch credentials must deny restoration" + ); + assert_eq!( + observer.calls.load(Ordering::Relaxed), + 0, + "launch authentication must precede lifecycle extension hooks" + ); + assert!(driver.registry.lock().await.is_empty()); + assert!(matches!( + events.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + assert!( + !extension_state_dir(&state_dir, "restore-observer") + .unwrap() + .exists() + ); + let mut retained_files = fs::read_dir(&state_dir) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + (entry.file_name(), fs::read(entry.path()).unwrap()) + }) + .collect::>(); + retained_files.sort(); + assert_eq!(retained_files, original_files); + } + } + } + #[test] fn host_control_receives_driver_owned_completion_marker() { let mut command = Command::new("openshell-sandbox"); From 95d1ff8567a655fdafa90587808895f780112493 Mon Sep 17 00:00:00 2001 From: Shiju Date: Wed, 30 Sep 2026 23:15:55 +0530 Subject: [PATCH 3/4] chore(gateway): satisfy launch preflight lint checks Signed-off-by: Shiju --- crates/openshell-core/src/extension_protocol.rs | 8 +++++--- crates/openshell-driver-vm/src/driver.rs | 2 +- crates/openshell-server/src/auth/launch_signing.rs | 4 ++-- crates/openshell-server/src/auth/mod.rs | 2 +- crates/openshell-server/src/defaults.rs | 2 +- crates/openshell-server/src/grpc/sandbox.rs | 10 ++++------ 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index fa90c9cde8..9c25c6cb87 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -12,9 +12,11 @@ use crate::proto::extension::v1::{PeerMetadata, ProtocolVersion}; pub const PROTOCOL_MAJOR: u32 = 1; pub const PROTOCOL_MINOR: u32 = 0; -/// Compute drivers require this capability when every launch needs a -/// gateway-minted [`crate::jwt::SandboxLaunchAuthentication`] bundle. The -/// gateway checks its configured signer before accepting sandbox creation. +/// Capability for compute drivers that require gateway-minted launch credentials. +/// +/// Drivers require this capability when every launch needs a +/// [`crate::jwt::SandboxLaunchAuthentication`] bundle. The gateway checks its +/// configured signer before accepting sandbox creation. pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication"; const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128; diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index a3ab0abdf9..2eeb3b14dc 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -9196,7 +9196,7 @@ mod tests { #[tonic::async_trait] impl LifecycleExtension for RestoreObserver { - fn name(&self) -> &str { + fn name(&self) -> &'static str { "restore-observer" } diff --git a/crates/openshell-server/src/auth/launch_signing.rs b/crates/openshell-server/src/auth/launch_signing.rs index 9a0bd9b3f1..a1b510bb24 100644 --- a/crates/openshell-server/src/auth/launch_signing.rs +++ b/crates/openshell-server/src/auth/launch_signing.rs @@ -9,12 +9,12 @@ use openshell_core::{Error, GatewayJwtConfig}; use super::sandbox_jwt::{ExtensionJwtIssuer, SandboxSessionJwtAuthority}; -pub(crate) struct LaunchSigningAuthorities { +pub struct LaunchSigningAuthorities { pub extension: Arc, pub sandbox_session: Arc, } -pub(crate) fn load(config: &GatewayJwtConfig) -> openshell_core::Result { +pub fn load(config: &GatewayJwtConfig) -> openshell_core::Result { let signing_pem = std::fs::read(&config.signing_key_path).map_err(|error| { Error::config(format!( "cannot read sandbox launch-signing private key from {}: {error}", diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index 4b1b44432c..da54287744 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -16,7 +16,7 @@ pub mod extension_mint_limit; pub mod guard; mod http; pub mod identity; -pub(crate) mod launch_signing; +pub mod launch_signing; pub mod method_authz; pub mod oidc; pub mod peer; diff --git a/crates/openshell-server/src/defaults.rs b/crates/openshell-server/src/defaults.rs index ca2cf556cd..390dad1eff 100644 --- a/crates/openshell-server/src/defaults.rs +++ b/crates/openshell-server/src/defaults.rs @@ -98,7 +98,7 @@ pub fn complete_local_jwt_config() -> Result> { local_jwt_config(&dir) } -pub(crate) fn local_jwt_config(dir: &Path) -> Result> { +pub fn local_jwt_config(dir: &Path) -> Result> { let paths = LocalJwtPaths::resolve(dir); let present = paths.files().iter().filter(|path| path.is_file()).count(); match present { diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index a95875fef6..f9b0050be5 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3980,22 +3980,20 @@ mod tests { .unwrap(); std::fs::write(&slot.upload_path, b"archive").unwrap(); let driver_config = Struct { - fields: [( + fields: std::iter::once(( "test".to_string(), Value { kind: Some(Kind::StructValue(Struct { - fields: [( + fields: std::iter::once(( crate::compute::rootfs_tar::STAGING_TOKEN_FIELD.to_string(), Value { kind: Some(Kind::StringValue(slot.token.clone())), }, - )] - .into_iter() + )) .collect(), })), }, - )] - .into_iter() + )) .collect(), }; driver.clear_calls(); From be1dd239d371eb5814fa57ca85328cc9eada26cf Mon Sep 17 00:00:00 2001 From: Shiju Date: Thu, 1 Oct 2026 03:17:59 +0530 Subject: [PATCH 4/4] docs(gateway): complete the MicroVM launch-signing example Include the gateway JWT paths in the standalone VM configuration and show the output directory required by local certificate generation. Explain the distinction between configuration preflight and launch-key validation. Refs #3949. Part of #3955. Signed-off-by: Shiju --- docs/how-it-works/gateways/configuration.mdx | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 4d8a3fb43b..8c8be5de4b 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -274,7 +274,7 @@ The client-certificate handshake policy is derived and has no `require_client_au `[openshell.gateway.gateway_jwt] ttl_secs` controls generation-bound gateway-facing and Sandbox Protocol credentials minted for a sandbox session, plus typed extension JWTs. Omit it for non-expiring local sandbox session credentials: both session tokens carry `exp = 0`, supervisors skip periodic session-token renewal, and refresh responses omit their expiration timestamps. Typed extension JWTs retain a 900-second default when the field is omitted. Use omission only for local single-player Docker, Podman, or VM gateways. Explicit `0` is invalid. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway omits the field. -Listener TLS and sandbox launch signing use separate keys. A working HTTPS listener or mTLS client certificate does not supply the signing key needed to start a VM sandbox. Configure `signing_key_path`, `public_key_path`, `kid_path`, and `gateway_id` in `[openshell.gateway.gateway_jwt]`, as shown above. For a local installation, `openshell-gateway generate-certs` writes a signing bundle alongside the TLS bundle. Without explicit `gateway_jwt` configuration, the gateway discovers `jwt/signing.pem`, `jwt/public.pem`, and `jwt/kid` under `OPENSHELL_LOCAL_TLS_DIR`, or under the OpenShell state directory's `tls/` directory when that variable is unset. Explicit signing configuration takes precedence over local discovery. +Listener TLS and sandbox launch signing use separate keys. A working HTTPS listener or mTLS client certificate does not supply the signing key needed to start a VM sandbox. Configure `signing_key_path`, `public_key_path`, `kid_path`, and `gateway_id` in `[openshell.gateway.gateway_jwt]`, as shown above. For a local installation, `openshell-gateway generate-certs --output-dir ` writes a signing bundle alongside the TLS bundle. Without explicit `gateway_jwt` configuration, the gateway discovers `jwt/signing.pem`, `jwt/public.pem`, and `jwt/kid` under `OPENSHELL_LOCAL_TLS_DIR`, or under the OpenShell state directory's `tls/` directory when that variable is unset. Explicit signing configuration takes precedence over local discovery. The gateway rejects partial or invalid signing bundles at startup, including private and public keys that do not match. When the selected driver requires launch signing and no bundle is configured, sandbox creation fails before the driver validates or prepares the sandbox. The error names the signing configuration and discovery location; it does not print keys or tokens. The VM driver also validates launch material before resolving images or creating sandbox state. @@ -1136,6 +1136,8 @@ runtime-selected profile. Each sandbox runs inside its own libkrun microVM managed by the standalone `openshell-driver-vm` subprocess. Use this driver when you want stronger isolation than container namespaces alone. +The example uses explicit launch-signing paths. Point them at an existing signing bundle, or omit the `gateway_jwt` table to use local discovery. Listener and guest TLS certificates do not supply these signing credentials. + ```toml [openshell] version = 2 @@ -1150,6 +1152,13 @@ guest_tls_ca = "/var/lib/openshell/guest-tls/ca.pem" guest_tls_cert = "/var/lib/openshell/guest-tls/client.pem" guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem" +# Sandbox launch signing is separate from listener and guest TLS. +[openshell.gateway.gateway_jwt] +signing_key_path = "/etc/openshell/jwt/signing.pem" +public_key_path = "/etc/openshell/jwt/public.pem" +kid_path = "/etc/openshell/jwt/kid" +gateway_id = "openshell" + [openshell.drivers.vm] state_dir = "/var/lib/openshell/vm" # Where the gateway looks for the openshell-driver-vm subprocess binary. @@ -1217,6 +1226,15 @@ overlay_disk_mib = 4096 # rootfs_tar_max_bytes = 10737418240 ``` +To generate a local bundle for discovery, run these commands in fish and retain the same environment when starting the gateway: + +```shell +set -gx OPENSHELL_LOCAL_TLS_DIR ~/.local/state/openshell/tls +openshell-gateway generate-certs --output-dir "$OPENSHELL_LOCAL_TLS_DIR" +``` + +The command also installs local CLI mTLS credentials. For explicit signing configuration, set the example's signing paths to `jwt/signing.pem`, `jwt/public.pem`, and `jwt/kid` under that directory. The gateway loads those paths in preference to local discovery. + Rootfs tar staging requires the gateway and the VM driver to share a filesystem and run as the same user. That holds for the managed VM driver, which the gateway starts as a subprocess. If you point `compute_driver_endpoints` at an @@ -1271,6 +1289,8 @@ not construct a compute driver or connect to a transport. A failed preflight always preserves the file; it never migrates, replaces, or rewrites configuration. +Config preflight validates configuration without loading launch-signing keys, so a successful result does not confirm that the key files exist or form a usable pair. Gateway startup validates configured or discovered signing bundles. Sandbox creation checks the selected driver's launch-signing requirement before image preparation. + To validate the exact daemon arguments that a wrapper will pass, place them after `--` instead of using `--path`: