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
9 changes: 8 additions & 1 deletion .agents/skills/debug-openshell-cluster/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,16 @@ Check required Helm deployment secrets:
kubectl -n openshell get secret \
openshell-server-tls \
openshell-server-client-ca \
openshell-client-tls
openshell-client-tls \
openshell-jwt-keys
```

If the gateway exits with `failed to read sandbox JWT signing key from
/etc/openshell-jwt/signing.pem`, verify that `openshell-jwt-keys` contains
`signing.pem`, `public.pem`, and `kid`, and that the StatefulSet mounts the
`sandbox-jwt` secret at `/etc/openshell-jwt`. The sandbox JWT mount is required
even when local Helm values disable TLS.

Check the image references currently used by the gateway deployment:

```bash
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openshell-bootstrap/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ bytes = { workspace = true }
futures = { workspace = true }
miette = { workspace = true }
rcgen = { workspace = true }
sha2 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tar = "0.4"
Expand Down
112 changes: 112 additions & 0 deletions crates/openshell-bootstrap/src/jwt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Gateway-minted JWT signing-key generation.
//!
//! The gateway mints per-sandbox identity tokens (see PR 2 of the
//! per-sandbox identity series, issue #1354) signed with an Ed25519
//! keypair generated once at gateway init and persisted alongside the
//! existing PKI bundle. The signing key never leaves the gateway; the
//! public key plus a stable `kid` are consumed by the gateway's own
//! validator and any future external verifiers.

use miette::{IntoDiagnostic, Result, WrapErr};
use rcgen::{KeyPair, PKCS_ED25519};
use sha2::{Digest, Sha256};

/// All PEM-encoded material needed to mint and validate sandbox JWTs.
///
/// The signing key stays in the gateway process. The public key is shared
/// across gateway replicas (so any replica can validate a JWT minted by
/// any other replica). The `kid` is published in every minted JWT's
/// header so the validator can pick the right key after a future rotation.
pub struct JwtKeyMaterial {
/// PKCS#8 PEM-encoded Ed25519 private key.
pub signing_key_pem: String,
/// `SubjectPublicKeyInfo` PEM-encoded Ed25519 public key.
pub public_key_pem: String,
/// Stable identifier derived from the public key (SHA-256 hex prefix).
/// Embedded in every minted JWT's `kid` header so future rotation can
/// be performed in-place by adding a second key without breaking
/// in-flight tokens.
pub kid: String,
}

/// Generate a fresh Ed25519 JWT signing key.
///
/// Output PEM is in the formats `jsonwebtoken` consumes via
/// `EncodingKey::from_ed_pem` (signing) and `DecodingKey::from_ed_pem`
/// (validation), so the gateway can round-trip its own tokens with no
/// further conversion.
pub fn generate_jwt_key() -> Result<JwtKeyMaterial> {
let keypair = KeyPair::generate_for(&PKCS_ED25519)
.into_diagnostic()
.wrap_err("failed to generate Ed25519 JWT signing key")?;
let signing_key_pem = keypair.serialize_pem();
let public_key_pem = keypair.public_key_pem();
let kid = kid_from_public_key_der(&keypair.public_key_der());
Ok(JwtKeyMaterial {
signing_key_pem,
public_key_pem,
kid,
})
}

/// Stable `kid` derived from the SHA-256 of the public-key DER.
///
/// First 16 bytes hex-encoded — collision-resistant for the small N of
/// signing keys a single deployment ever has, while staying short enough
/// to keep JWT headers compact.
fn kid_from_public_key_der(public_key_der: &[u8]) -> String {
let digest = Sha256::digest(public_key_der);
hex_encode_prefix(&digest, 16)
}

fn hex_encode_prefix(bytes: &[u8], n: usize) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(n * 2);
for byte in bytes.iter().take(n) {
let _ = write!(out, "{byte:02x}");
}
out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn generate_jwt_key_produces_parseable_pem() {
let material = generate_jwt_key().expect("generate_jwt_key");
assert!(material.signing_key_pem.contains("BEGIN PRIVATE KEY"));
assert!(material.public_key_pem.contains("BEGIN PUBLIC KEY"));
assert_eq!(material.kid.len(), 32, "kid is 16 bytes hex-encoded");
assert!(material.kid.chars().all(|c| c.is_ascii_hexdigit()));
}

#[test]
fn kid_is_stable_for_identical_public_keys() {
// Same input -> same kid. Hash of a fixed byte string.
let kid_a = kid_from_public_key_der(b"abc");
let kid_b = kid_from_public_key_der(b"abc");
assert_eq!(kid_a, kid_b);
}

#[test]
fn kid_differs_for_different_public_keys() {
let kid_a = kid_from_public_key_der(b"first");
let kid_b = kid_from_public_key_der(b"second");
assert_ne!(kid_a, kid_b);
}

#[test]
fn generated_keys_are_unique() {
let a = generate_jwt_key().expect("generate_jwt_key");
let b = generate_jwt_key().expect("generate_jwt_key");
assert_ne!(
a.kid, b.kid,
"fresh keypairs must produce distinct public keys"
);
assert_ne!(a.signing_key_pem, b.signing_key_pem);
}
}
1 change: 1 addition & 0 deletions crates/openshell-bootstrap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

pub mod build;
pub mod edge_token;
pub mod jwt;
pub mod oidc_token;

mod metadata;
Expand Down
20 changes: 20 additions & 0 deletions crates/openshell-bootstrap/src/pki.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use crate::jwt::{JwtKeyMaterial, generate_jwt_key};
use miette::{IntoDiagnostic, Result, WrapErr};
use rcgen::{BasicConstraints, CertificateParams, DnType, Ia5String, IsCa, KeyPair, SanType};
use std::net::IpAddr;
Expand All @@ -15,6 +16,12 @@ pub struct PkiBundle {
pub server_key_pem: String,
pub client_cert_pem: String,
pub client_key_pem: String,
/// PKCS#8 PEM Ed25519 private key for minting per-sandbox JWTs.
pub jwt_signing_key_pem: String,
/// SPKI PEM Ed25519 public key, paired with `jwt_signing_key_pem`.
pub jwt_public_key_pem: String,
/// Stable identifier embedded in the `kid` header of every minted JWT.
pub jwt_key_id: String,
}

/// Default SANs always included on the server certificate.
Expand Down Expand Up @@ -95,13 +102,23 @@ pub fn generate_pki(extra_sans: &[String]) -> Result<PkiBundle> {
.into_diagnostic()
.wrap_err("failed to sign client certificate")?;

// --- JWT signing key (Ed25519, used to mint per-sandbox identity tokens) ---
let JwtKeyMaterial {
signing_key_pem: jwt_signing_key_pem,
public_key_pem: jwt_public_key_pem,
kid: jwt_key_id,
} = generate_jwt_key().wrap_err("failed to generate JWT signing key")?;

Ok(PkiBundle {
ca_cert_pem: ca_cert.pem(),
ca_key_pem: ca_key.serialize_pem(),
server_cert_pem: server_cert.pem(),
server_key_pem: server_key.serialize_pem(),
client_cert_pem: client_cert.pem(),
client_key_pem: client_key.serialize_pem(),
jwt_signing_key_pem,
jwt_public_key_pem,
jwt_key_id,
})
}

Expand Down Expand Up @@ -144,6 +161,9 @@ mod tests {
assert!(bundle.server_key_pem.contains("BEGIN PRIVATE KEY"));
assert!(bundle.client_cert_pem.contains("BEGIN CERTIFICATE"));
assert!(bundle.client_key_pem.contains("BEGIN PRIVATE KEY"));
assert!(bundle.jwt_signing_key_pem.contains("BEGIN PRIVATE KEY"));
assert!(bundle.jwt_public_key_pem.contains("BEGIN PUBLIC KEY"));
assert_eq!(bundle.jwt_key_id.len(), 32, "kid is 16 bytes hex-encoded");
}

#[test]
Expand Down
5 changes: 5 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,11 @@ fn import_local_package_mtls_bundle(name: &str) -> Result<Option<PathBuf>> {
client_key_pem: std::fs::read_to_string(&key)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", key.display()))?,
// CLI never holds the gateway's JWT signing material — only the
// gateway needs it. Fill the JWT fields with placeholders.
jwt_signing_key_pem: String::new(),
jwt_public_key_pem: String::new(),
jwt_key_id: String::new(),
};
openshell_bootstrap::mtls::store_pki_bundle(name, &bundle)
.wrap_err_with(|| format!("failed to store mTLS bundle for gateway '{name}'"))?;
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn issue_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::IssueSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::IssueSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn refresh_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::RefreshSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn connect_supervisor(
&self,
_request: tonic::Request<tonic::Streaming<SupervisorMessage>>,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn issue_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::IssueSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::IssueSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn refresh_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::RefreshSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn connect_supervisor(
&self,
_request: tonic::Request<tonic::Streaming<openshell_core::proto::SupervisorMessage>>,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn issue_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::IssueSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::IssueSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn refresh_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::RefreshSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn connect_supervisor(
&self,
_request: tonic::Request<tonic::Streaming<SupervisorMessage>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn issue_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::IssueSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::IssueSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn refresh_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::RefreshSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn connect_supervisor(
&self,
_request: tonic::Request<tonic::Streaming<SupervisorMessage>>,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/sandbox_name_fallback_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("not implemented in test"))
}

async fn issue_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::IssueSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::IssueSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn refresh_sandbox_token(
&self,
_request: tonic::Request<openshell_core::proto::RefreshSandboxTokenRequest>,
) -> Result<Response<openshell_core::proto::RefreshSandboxTokenResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn connect_supervisor(
&self,
_request: tonic::Request<tonic::Streaming<SupervisorMessage>>,
Expand Down
Loading
Loading