Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/openshell-core/src/extension_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ use crate::proto::extension::v1::{PeerMetadata, ProtocolVersion};
pub const PROTOCOL_MAJOR: u32 = 1;
pub const PROTOCOL_MINOR: u32 = 0;

/// 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;
const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128;
const MAX_CAPABILITY_BYTES: usize = 128;
Expand Down Expand Up @@ -103,14 +110,18 @@ 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,
minor: PROTOCOL_MINOR,
}),
implementation_name: "openshell/gateway".to_string(),
implementation_version: crate::VERSION.to_string(),
supported_capabilities: vec![contract.clone()],
supported_capabilities,
required_capabilities: vec![contract],
}
}
Expand Down
402 changes: 361 additions & 41 deletions crates/openshell-driver-vm/src/driver.rs

Large diffs are not rendered by default.

201 changes: 201 additions & 0 deletions crates/openshell-server/src/auth/launch_signing.rs
Original file line number Diff line number Diff line change
@@ -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 struct LaunchSigningAuthorities {
pub extension: Arc<ExtensionJwtIssuer>,
pub sandbox_session: Arc<SandboxSessionJwtAuthority>,
}

pub fn load(config: &GatewayJwtConfig) -> openshell_core::Result<LaunchSigningAuthorities> {
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"));
}
}
1 change: 1 addition & 0 deletions crates/openshell-server/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod extension_mint_limit;
pub mod guard;
mod http;
pub mod identity;
pub mod launch_signing;
pub mod method_authz;
pub mod oidc;
pub mod peer;
Expand Down
67 changes: 58 additions & 9 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
);
}
}
Loading
Loading