Skip to content

Commit 021400b

Browse files
authored
refactor(auth): separate sandbox identity from TLS (#3110)
* refactor(auth): separate sandbox identity from TLS Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(auth): clarify gateway mTLS behavior Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(auth): include workspace scope in TLS authorization checks Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(e2e): bound service auth sandbox names for large PIDs Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 2935e97 commit 021400b

53 files changed

Lines changed: 473 additions & 916 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/helm-dev-environment/SKILL.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ capability-free workload Pod and a directly managed capability-free supervisor
8383
Pod. One namespace-wide NetworkPolicy denies direct egress from every OpenShell
8484
workload Pod. The
8585
`pkiInitJob` hook (a pre-install Job that runs `openshell-gateway generate-certs`)
86-
generates mTLS secrets on first install. The default Skaffold values export
86+
generates gateway and CLI TLS secrets on first install. Supervisor Pods project
87+
only `ca.crt` and authenticate gateway RPCs with sandbox bearer tokens. User
88+
client certificates and private keys remain outside supervisor and workload Pods.
89+
The default Skaffold values export
8790
gateway and Kubernetes-driver traces to the collector service installed by
8891
`helm:k3s:create`. Envoy Gateway is opt-in; see the Optional Add-ons section.
8992

‎crates/openshell-bootstrap/src/pki.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ pub fn generate_pki(extra_sans: &[String]) -> Result<PkiBundle> {
9393
.into_diagnostic()
9494
.wrap_err("failed to sign server certificate")?;
9595

96-
// --- Client cert (shared by CLI and sandbox pods) ---
96+
// --- User client cert (CLI only; sandboxes use bearer identity) ---
9797
let client_key = KeyPair::generate()
9898
.into_diagnostic()
9999
.wrap_err("failed to generate client key")?;

‎crates/openshell-core/src/config.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -378,13 +378,13 @@ pub struct OidcConfig {
378378
pub scopes_claim: String,
379379
}
380380

381-
/// mTLS user authentication for local, single-user gateways.
381+
/// mTLS user authentication for gateway users.
382382
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
383383
#[serde(deny_unknown_fields)]
384384
pub struct MtlsAuthConfig {
385385
/// When true, the gateway maps a verified TLS client certificate into a
386-
/// user principal. Keep disabled for Kubernetes deployments because
387-
/// Kubernetes sandbox pods and external users must not share user auth.
386+
/// user principal. Sandbox and supervisor clients use bearer identity, so
387+
/// this setting is independent of the selected compute driver.
388388
#[serde(default)]
389389
pub enabled: bool,
390390
}

‎crates/openshell-core/src/container_paths.rs‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ pub const SUPERVISOR_CONTAINER_DIR: &str = "/opt/openshell/bin";
4444
pub const SUPERVISOR_CONTAINER_BINARY: &str = "/opt/openshell/bin/openshell-sandbox";
4545
pub const TLS_CLIENT_DIR: &str = "/etc/openshell/tls/client";
4646
pub const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt";
47-
pub const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt";
48-
pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key";
4947
pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt";
5048
pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy";
5149
pub const CONTAINER_POLICY_PATH: &str = "/etc/openshell/policy.yaml";
@@ -60,8 +58,6 @@ pub const SUPERVISOR_CA_CERT_PATH: &str = "/etc/openshell-tls/openshell-ca.pem";
6058
pub const SUPERVISOR_CA_BUNDLE_PATH: &str = "/etc/openshell-tls/ca-bundle.pem";
6159

6260
pub const VM_GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt";
63-
pub const VM_GUEST_TLS_CERT_PATH: &str = "/opt/openshell/tls/tls.crt";
64-
pub const VM_GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key";
6561
pub const VM_GUEST_SANDBOX_TOKEN_PATH: &str = "/opt/openshell/auth/sandbox.jwt";
6662
pub const VM_GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d";
6763
pub const VM_GUEST_INIT_DROPIN_MANIFEST: &str = "/opt/openshell/init.d.manifest";
@@ -109,8 +105,6 @@ mod tests {
109105
SUPERVISOR_CONTAINER_BINARY,
110106
TLS_CLIENT_DIR,
111107
TLS_CA_MOUNT_PATH,
112-
TLS_CERT_MOUNT_PATH,
113-
TLS_KEY_MOUNT_PATH,
114108
SANDBOX_TOKEN_MOUNT_PATH,
115109
UPSTREAM_PROXY_AUTH_MOUNT_PATH,
116110
CONTAINER_POLICY_PATH,
@@ -123,8 +117,6 @@ mod tests {
123117
SUPERVISOR_CA_CERT_PATH,
124118
SUPERVISOR_CA_BUNDLE_PATH,
125119
VM_GUEST_TLS_CA_PATH,
126-
VM_GUEST_TLS_CERT_PATH,
127-
VM_GUEST_TLS_KEY_PATH,
128120
VM_GUEST_SANDBOX_TOKEN_PATH,
129121
VM_GUEST_UPSTREAM_PROXY_AUTH_PATH,
130122
VM_GUEST_PROXY_CA_PATH,

‎crates/openshell-core/src/driver_utils.rs‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,22 +104,15 @@ pub const SUPERVISOR_CONTAINER_BINARY: &str = "/opt/openshell/bin/openshell-sand
104104
// ---------------------------------------------------------------------------
105105
// In-container mount paths for guest TLS materials and the sandbox token.
106106
//
107-
// All container-based drivers (Docker, Podman, Kubernetes) mount the gateway's
108-
// mTLS client credentials at these fixed paths inside every sandbox container.
109-
// The supervisor reads these paths on startup to establish its gRPC-over-mTLS
110-
// connection back to the gateway. The paths must remain stable across driver
111-
// versions since the supervisor binary is built and packaged separately.
107+
// Container-based drivers mount the gateway CA at this fixed path inside every
108+
// supervisor container. The supervisor reads it on startup to authenticate the
109+
// gateway TLS endpoint. Sandbox identity is provided separately by a bearer
110+
// token.
112111
// ---------------------------------------------------------------------------
113112

114-
/// Container-side mount path for the guest mTLS CA certificate.
113+
/// Container-side mount path for the gateway CA certificate.
115114
pub const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt";
116115

117-
/// Container-side mount path for the guest mTLS client certificate.
118-
pub const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt";
119-
120-
/// Container-side mount path for the guest mTLS client private key.
121-
pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key";
122-
123116
/// Container-side mount path for the per-sandbox JWT token.
124117
pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt";
125118

‎crates/openshell-core/src/grpc_client.rs‎

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ use openshell_extension_core::{BearerTokenSlot, ExtensionCredentialStore};
4040
use tonic::Status;
4141
use tonic::metadata::AsciiMetadataValue;
4242
use tonic::service::interceptor::InterceptedService;
43-
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
43+
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint};
4444
use tracing::{debug, info, warn};
4545

4646
/// Preserve the gRPC status as a source so callers can classify retryable errors.
@@ -201,10 +201,9 @@ impl tonic::service::Interceptor for AuthInterceptor {
201201

202202
/// Build the plain (un-intercepted) gRPC channel.
203203
///
204-
/// When the endpoint uses `https://`, mTLS is configured using these env vars:
204+
/// When the endpoint uses `https://`, server-authenticated TLS is configured
205+
/// using this env var:
205206
/// - `OPENSHELL_TLS_CA` -- path to the CA certificate
206-
/// - `OPENSHELL_TLS_CERT` -- path to the client certificate
207-
/// - `OPENSHELL_TLS_KEY` -- path to the client private key
208207
///
209208
/// When the endpoint uses `http://`, a plaintext connection is used (for
210209
/// deployments where TLS is disabled, e.g. behind a Cloudflare Tunnel).
@@ -223,34 +222,20 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
223222

224223
let tls_enabled = endpoint.starts_with("https://");
225224

226-
// TODO: TLS certs are loaded once here and never re-read. The gateway
225+
// TODO: The TLS CA is loaded once here and never re-read. The gateway
227226
// server side supports hot-reload (ArcSwap + notify in tls.rs). The
228227
// supervisor should do the same so that cert-manager rotations take
229228
// effect without restarting the sandbox.
230229
if tls_enabled {
231230
let ca_path = std::env::var(sandbox_env::TLS_CA)
232231
.into_diagnostic()
233232
.wrap_err("OPENSHELL_TLS_CA is required")?;
234-
let cert_path = std::env::var(sandbox_env::TLS_CERT)
235-
.into_diagnostic()
236-
.wrap_err("OPENSHELL_TLS_CERT is required")?;
237-
let key_path = std::env::var(sandbox_env::TLS_KEY)
238-
.into_diagnostic()
239-
.wrap_err("OPENSHELL_TLS_KEY is required")?;
240-
241233
let ca_pem = std::fs::read(&ca_path)
242234
.into_diagnostic()
243235
.wrap_err_with(|| format!("failed to read CA cert from {ca_path}"))?;
244-
let cert_pem = std::fs::read(&cert_path)
245-
.into_diagnostic()
246-
.wrap_err_with(|| format!("failed to read client cert from {cert_path}"))?;
247-
let key_pem = std::fs::read(&key_path)
248-
.into_diagnostic()
249-
.wrap_err_with(|| format!("failed to read client key from {key_path}"))?;
250236

251237
// Trust only the configured CA — this is the chart's internal CA
252-
// that signs both the gateway's internal server certificate and
253-
// this client's identity certificate. The gateway uses SNI-based
238+
// that signs the gateway's internal server certificate. The gateway uses SNI-based
254239
// certificate selection to present this internal cert to supervisor
255240
// connections, so no public root trust is needed here.
256241
//
@@ -259,9 +244,7 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
259244
// (Docker/Podman drivers), and broadening the trust store would let
260245
// an attacker who controls the image + DNS present a publicly valid
261246
// certificate and intercept the supervisor→gateway TLS connection.
262-
let mut tls_config = ClientTlsConfig::new()
263-
.ca_certificate(Certificate::from_pem(ca_pem))
264-
.identity(Identity::from_pem(cert_pem, key_pem));
247+
let mut tls_config = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem));
265248
if let Ok(server_name) = std::env::var(sandbox_env::GATEWAY_TLS_SERVER_NAME)
266249
&& !server_name.is_empty()
267250
{

‎crates/openshell-core/src/sandbox_env.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ pub const PROXY_CA_KEY: &str = "OPENSHELL_PROXY_CA_KEY";
185185
/// Whether the control-owned SSH Unix socket is shared across trusted UIDs.
186186
pub const SSH_SOCKET_SHARED: &str = "OPENSHELL_SSH_SOCKET_SHARED";
187187

188-
/// Path to the CA certificate for mTLS communication with the gateway.
188+
/// Path to the CA certificate used to authenticate the gateway TLS endpoint.
189189
pub const TLS_CA: &str = "OPENSHELL_TLS_CA";
190190

191191
/// Path to the client certificate for mTLS communication with the gateway.

‎crates/openshell-driver-docker/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ The driver publishes host loopback as the backend address for
177177
mediated path, so policies can reach host services without a Docker bridge,
178178
container DNS alias, or another gateway listener.
179179

180+
For HTTPS endpoints, the supervisor receives only the gateway CA and
181+
uses its sandbox-scoped bearer token to authenticate RPCs. User client
182+
certificates and private keys are not delivered to either container.
183+
180184
Docker Engine on Linux supports host networking directly. Docker Desktop
181185
requires host networking to be enabled in Settings and does not support it
182186
when Enhanced Container Isolation is enabled.
@@ -189,7 +193,7 @@ The supervisor owns these security-critical variables:
189193
- `OPENSHELL_SANDBOX_TOKEN_FILE`
190194
- `OPENSHELL_SSH_SOCKET_PATH`
191195
- `OPENSHELL_MAIN_PROCESS_SPEC`
192-
- TLS path variables when HTTPS is enabled
196+
- `OPENSHELL_TLS_CA` when HTTPS is enabled
193197

194198
Template and sandbox environment is encoded in the protected bootstrap and
195199
exposed only to workload children. Workload input cannot override

‎crates/openshell-driver-docker/src/lib.rs‎

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,15 @@ pub struct DockerComputeConfig {
189189
/// Image containing the trusted `openshell-supervisor` binary.
190190
pub supervisor_image: Option<String>,
191191

192-
/// Host-side CA certificate for Docker sandbox mTLS.
192+
/// Host-side CA certificate for sandbox-to-gateway TLS.
193193
pub guest_tls_ca: Option<PathBuf>,
194194

195-
/// Host-side client certificate for Docker sandbox mTLS.
195+
/// Deprecated. Sandboxes authenticate with bearer tokens and must not
196+
/// receive a user client certificate.
196197
pub guest_tls_cert: Option<PathBuf>,
197198

198-
/// Host-side private key for Docker sandbox mTLS.
199+
/// Deprecated. Sandboxes authenticate with bearer tokens and must not
200+
/// receive a user client private key.
199201
pub guest_tls_key: Option<PathBuf>,
200202

201203
/// Unix socket path used for interactive sandbox access.
@@ -293,8 +295,6 @@ impl Default for DockerComputeConfig {
293295
#[derive(Debug, Clone, PartialEq, Eq)]
294296
pub(crate) struct DockerGuestTlsPaths {
295297
pub(crate) ca: PathBuf,
296-
pub(crate) cert: PathBuf,
297-
pub(crate) key: PathBuf,
298298
}
299299

300300
#[derive(Debug, Clone)]
@@ -4676,11 +4676,7 @@ async fn docker_supervisor_bundle_archive(
46764676
SUPERVISOR_UID,
46774677
SUPERVISOR_GID,
46784678
)?;
4679-
for (name, path) in [
4680-
("ca.pem", &tls.ca),
4681-
("cert.pem", &tls.cert),
4682-
("key.pem", &tls.key),
4683-
] {
4679+
for (name, path) in [("ca.pem", &tls.ca)] {
46844680
let contents = tokio::fs::read(path).await.map_err(|error| {
46854681
Status::internal(format!(
46864682
"read Docker supervisor TLS file {}: {error}",
@@ -5097,20 +5093,10 @@ async fn spawn_docker_control_process(
50975093
),
50985094
];
50995095
if config.guest_tls.is_some() {
5100-
environment.extend([
5101-
format!(
5102-
"{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/ca.pem",
5103-
openshell_core::sandbox_env::TLS_CA
5104-
),
5105-
format!(
5106-
"{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/cert.pem",
5107-
openshell_core::sandbox_env::TLS_CERT
5108-
),
5109-
format!(
5110-
"{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/key.pem",
5111-
openshell_core::sandbox_env::TLS_KEY
5112-
),
5113-
]);
5096+
environment.push(format!(
5097+
"{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/ca.pem",
5098+
openshell_core::sandbox_env::TLS_CA
5099+
));
51145100
}
51155101
if let Some(socket) = config.provider_spiffe_workload_api_socket.as_ref() {
51165102
let projected = openshell_core::driver_utils::projected_provider_spiffe_socket_path(socket)
@@ -6505,8 +6491,6 @@ fn canonicalize_existing_file(path: &Path, description: &str) -> CoreResult<Path
65056491

65066492
fn docker_guest_tls_configured(docker_config: &DockerComputeConfig) -> bool {
65076493
docker_config.guest_tls_ca.is_some()
6508-
&& docker_config.guest_tls_cert.is_some()
6509-
&& docker_config.guest_tls_key.is_some()
65106494
}
65116495

65126496
fn default_docker_supervisor_grpc_endpoint(gateway_port: u16, tls: bool) -> String {
@@ -6521,24 +6505,25 @@ pub(crate) fn docker_guest_tls_paths(
65216505
|| docker_config.guest_tls_cert.is_some()
65226506
|| docker_config.guest_tls_key.is_some();
65236507

6508+
if docker_config.guest_tls_cert.is_some() || docker_config.guest_tls_key.is_some() {
6509+
return Err(Error::config(
6510+
"guest_tls_cert and guest_tls_key are no longer supported; sandboxes authenticate to the gateway with bearer tokens",
6511+
));
6512+
}
6513+
65246514
if !docker_config.grpc_endpoint.starts_with("https://") {
65256515
if tls_flags_provided {
65266516
return Err(Error::config(format!(
6527-
"guest_tls_ca/guest_tls_cert/guest_tls_key were provided but grpc_endpoint is '{}'; TLS materials require an https:// endpoint",
6517+
"guest_tls_ca was provided but grpc_endpoint is '{}'; TLS materials require an https:// endpoint",
65286518
docker_config.grpc_endpoint,
65296519
)));
65306520
}
65316521
return Ok(None);
65326522
}
65336523

6534-
let provided = [
6535-
docker_config.guest_tls_ca.as_ref(),
6536-
docker_config.guest_tls_cert.as_ref(),
6537-
docker_config.guest_tls_key.as_ref(),
6538-
];
6539-
if provided.iter().all(Option::is_none) {
6524+
if docker_config.guest_tls_ca.is_none() {
65406525
return Err(Error::config(
6541-
"docker compute driver requires guest_tls_ca, guest_tls_cert, and guest_tls_key when grpc_endpoint uses https://",
6526+
"docker compute driver requires guest_tls_ca when grpc_endpoint uses https://",
65426527
));
65436528
}
65446529

@@ -6547,21 +6532,8 @@ pub(crate) fn docker_guest_tls_paths(
65476532
"guest_tls_ca is required when Docker sandbox TLS materials are configured",
65486533
));
65496534
};
6550-
let Some(cert) = docker_config.guest_tls_cert.clone() else {
6551-
return Err(Error::config(
6552-
"guest_tls_cert is required when Docker sandbox TLS materials are configured",
6553-
));
6554-
};
6555-
let Some(key) = docker_config.guest_tls_key.clone() else {
6556-
return Err(Error::config(
6557-
"guest_tls_key is required when Docker sandbox TLS materials are configured",
6558-
));
6559-
};
6560-
65616535
Ok(Some(DockerGuestTlsPaths {
65626536
ca: canonicalize_existing_file(&ca, "docker TLS CA certificate")?,
6563-
cert: canonicalize_existing_file(&cert, "docker TLS client certificate")?,
6564-
key: canonicalize_existing_file(&key, "docker TLS client private key")?,
65656537
}))
65666538
}
65676539

‎crates/openshell-driver-docker/src/tests.rs‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,6 @@ fn runtime_config() -> DockerDriverRuntimeConfig {
188188
ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(),
189189
guest_tls: Some(DockerGuestTlsPaths {
190190
ca: PathBuf::from("/tmp/ca.crt"),
191-
cert: PathBuf::from("/tmp/tls.crt"),
192-
key: PathBuf::from("/tmp/tls.key"),
193191
}),
194192
gpu: DockerGpuRuntimeCapabilities {
195193
cdi_supported: false,
@@ -3237,18 +3235,19 @@ fn workload_mounts_only_the_shared_channel_volume() {
32373235
}
32383236

32393237
#[test]
3240-
fn docker_guest_tls_paths_require_all_files_for_https() {
3238+
fn docker_guest_tls_paths_accept_ca_only_for_https() {
32413239
let tempdir = TempDir::new().unwrap();
32423240
let ca = tempdir.path().join("ca.crt");
32433241
fs::write(&ca, b"ca").unwrap();
32443242

3245-
let err = docker_guest_tls_paths(&DockerComputeConfig {
3243+
let paths = docker_guest_tls_paths(&DockerComputeConfig {
32463244
grpc_endpoint: "https://localhost:8443".to_string(),
3247-
guest_tls_ca: Some(ca),
3245+
guest_tls_ca: Some(ca.clone()),
32483246
..Default::default()
32493247
})
3250-
.unwrap_err();
3251-
assert!(err.to_string().contains("guest_tls_cert"));
3248+
.unwrap()
3249+
.expect("CA-only TLS paths");
3250+
assert_eq!(paths.ca, ca.canonicalize().unwrap());
32523251
}
32533252

32543253
#[test]

0 commit comments

Comments
 (0)