Skip to content

Commit 9fd41e6

Browse files
fix(ssh): persist sandbox host identities (#4094)
* fix(ssh): persist sandbox host identities Store each sandbox's Ed25519 host key in the gateway credential store and deliver it only to the supervisor. Preserve identity across restarts, delete owned credentials with the sandbox, and expose the public SHA256 fingerprint through sandbox and SSH-session APIs and client SDKs. Cover credential ownership, cancellation, deletion retries, client compatibility, and pinned SSH connections through lifecycle transitions. Closes #3835 Signed-off-by: Mike Nguyen <miken@nvidia.com> * fix(compute): clean up failed sandbox SSH identity creation Signed-off-by: Mike Nguyen <miken@nvidia.com> * test(ssh): wait for sandbox deletion before name reuse Signed-off-by: Mike Nguyen <miken@nvidia.com> --------- Signed-off-by: Mike Nguyen <miken@nvidia.com>
1 parent 5601d71 commit 9fd41e6

38 files changed

Lines changed: 1806 additions & 184 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/openshell-cli/src/run.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2995,6 +2995,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value {
29952995
"id": sandbox.object_id(),
29962996
"name": sandbox.object_name(),
29972997
"workspace": sandbox.object_workspace(),
2998+
"host_key_fingerprint": sandbox.host_key_fingerprint,
29982999
"labels": labels,
29993000
"annotations": annotations,
30003001
"resource_version": meta.map_or(0, |m| m.resource_version),

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,46 @@ mod session {
229229
}
230230
}
231231

232+
/// Private SSH key material is redacted from diagnostics and cleared on drop.
233+
#[derive(Clone)]
234+
pub struct SecretSshHostKey(Zeroizing<String>);
235+
236+
impl SecretSshHostKey {
237+
#[must_use]
238+
pub fn new(value: String) -> Self {
239+
Self(Zeroizing::new(value))
240+
}
241+
242+
#[must_use]
243+
pub fn expose_secret(&self) -> &str {
244+
&self.0
245+
}
246+
}
247+
248+
impl fmt::Debug for SecretSshHostKey {
249+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
250+
formatter.write_str("SecretSshHostKey([REDACTED])")
251+
}
252+
}
253+
254+
impl Serialize for SecretSshHostKey {
255+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
256+
where
257+
S: serde::Serializer,
258+
{
259+
serializer.serialize_str(self.expose_secret())
260+
}
261+
}
262+
263+
impl<'de> Deserialize<'de> for SecretSshHostKey {
264+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265+
where
266+
D: serde::Deserializer<'de>,
267+
{
268+
String::deserialize(deserializer).map(Self::new)
269+
}
270+
}
271+
232272
/// Trusted launch input delivered only to `openshell-supervisor`.
233273
#[derive(Clone, Serialize, Deserialize)]
234274
#[serde(deny_unknown_fields)]
@@ -244,6 +284,9 @@ mod session {
244284
pub gateway_expires_at: i64,
245285
pub sandbox_token: SecretJwt,
246286
pub sandbox_expires_at: i64,
287+
/// Never delivered to the workload. Missing only in older bundles.
288+
#[serde(default, skip_serializing_if = "Option::is_none")]
289+
pub ssh_host_private_key: Option<SecretSshHostKey>,
247290
}
248291

249292
/// Gateway-created authentication input trusted by a compute driver.
@@ -329,6 +372,7 @@ mod session {
329372
.field("gateway_expires_at", &self.gateway_expires_at)
330373
.field("sandbox_token", &"[REDACTED]")
331374
.field("sandbox_expires_at", &self.sandbox_expires_at)
375+
.field("ssh_host_private_key", &self.ssh_host_private_key)
332376
.finish()
333377
}
334378
}
@@ -1034,6 +1078,7 @@ mod tests {
10341078
gateway_expires_at: pair.gateway.expires_at,
10351079
sandbox_token: pair.sandbox.token,
10361080
sandbox_expires_at: pair.sandbox.expires_at,
1081+
ssh_host_private_key: None,
10371082
};
10381083
bundle.validate().expect("non-expiring auth bundle");
10391084
}
@@ -1060,6 +1105,9 @@ mod tests {
10601105
gateway_expires_at: pair.gateway.expires_at,
10611106
sandbox_token: pair.sandbox.token,
10621107
sandbox_expires_at: pair.sandbox.expires_at,
1108+
ssh_host_private_key: Some(SecretSshHostKey::new(
1109+
"private-ssh-host-key".to_string(),
1110+
)),
10631111
};
10641112

10651113
let encoded = serde_json::to_vec(&bundle).expect("serialize auth bundle");
@@ -1078,7 +1126,19 @@ mod tests {
10781126
let debug = format!("{bundle:?}");
10791127
assert!(!debug.contains(bundle.gateway_token.expose_secret()));
10801128
assert!(!debug.contains(bundle.sandbox_token.expose_secret()));
1081-
assert_eq!(debug.matches("[REDACTED]").count(), 2);
1129+
assert!(!debug.contains("private-ssh-host-key"));
1130+
assert_eq!(debug.matches("[REDACTED]").count(), 3);
1131+
assert_eq!(
1132+
decoded.ssh_host_private_key.unwrap().expose_secret(),
1133+
"private-ssh-host-key"
1134+
);
1135+
let mut legacy = serde_json::to_value(&bundle).unwrap();
1136+
legacy
1137+
.as_object_mut()
1138+
.unwrap()
1139+
.remove("ssh_host_private_key");
1140+
let legacy: SupervisorAuthBundle = serde_json::from_value(legacy).unwrap();
1141+
assert!(legacy.ssh_host_private_key.is_none());
10821142
}
10831143

10841144
#[derive(Serialize)]

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ fn test_launch_authentication() -> Vec<u8> {
6363
gateway_expires_at: i64::MAX,
6464
sandbox_token: SecretJwt::parse("sandbox.token.value").unwrap(),
6565
sandbox_expires_at: i64::MAX,
66+
ssh_host_private_key: None,
6667
},
6768
gateway_id: "gateway-test".to_string(),
6869
verification_keys: vec![SessionVerificationKey {

‎crates/openshell-driver-podman/src/driver.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,6 +2106,7 @@ mod tests {
21062106
gateway_expires_at: i64::MAX,
21072107
sandbox_token: SecretJwt::parse("sandbox.token.value").unwrap(),
21082108
sandbox_expires_at: i64::MAX,
2109+
ssh_host_private_key: None,
21092110
},
21102111
gateway_id: "gateway-test".to_string(),
21112112
verification_keys: vec![SessionVerificationKey {

‎crates/openshell-driver-podman/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ mod tests {
445445
gateway_expires_at: i64::MAX,
446446
sandbox_token: SecretJwt::parse("sandbox.token.value").unwrap(),
447447
sandbox_expires_at: i64::MAX,
448+
ssh_host_private_key: None,
448449
},
449450
gateway_id: "gateway-test".to_string(),
450451
verification_keys: vec![SessionVerificationKey {

‎crates/openshell-driver-vm/src/driver.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10047,6 +10047,7 @@ mod tests {
1004710047
gateway_expires_at: 1,
1004810048
sandbox_token: SecretJwt::parse(format!("sandbox-{label}")).expect("sandbox token"),
1004910049
sandbox_expires_at: 1,
10050+
ssh_host_private_key: None,
1005010051
},
1005110052
gateway_id: "gateway-a".to_string(),
1005210053
verification_keys: vec![SessionVerificationKey {

‎crates/openshell-gateway/tests/config_preflight.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ async fn local_vm_rejects_hanging_tool_with_deadline() {
184184
let output = fixture.run(&[]).await;
185185
assert!(!output.status.success());
186186
assert!(
187-
combined(&output).contains("timed out after 5 seconds"),
187+
normalized_diagnostic(&output).contains("timed out after 5 seconds"),
188188
"{}",
189189
combined(&output)
190190
);

‎crates/openshell-sdk/src/types.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,8 @@ pub struct SandboxRef {
384384
pub labels: HashMap<String, String>,
385385
pub resource_version: u64,
386386
pub exit_code: Option<i32>,
387+
/// Public OpenSSH SHA256 host identity; absent on older gateways.
388+
pub host_key_fingerprint: Option<String>,
387389
pub created_from_workload_template: Option<SandboxWorkloadTemplateProvenance>,
388390
/// Service URLs returned by sandbox creation, keyed by service name. The
389391
/// empty key identifies the unnamed service. Non-create reads leave this empty.
@@ -437,6 +439,8 @@ impl SandboxRef {
437439
labels: meta.labels,
438440
resource_version: meta.resource_version,
439441
exit_code,
442+
host_key_fingerprint: (!sandbox.host_key_fingerprint.is_empty())
443+
.then_some(sandbox.host_key_fingerprint),
440444
created_from_workload_template,
441445
service_urls: HashMap::new(),
442446
restart_count,

‎crates/openshell-sdk/tests/client_mock.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ fn sandbox_with_phase_ws(
131131
..Default::default()
132132
}),
133133
created_from_workload_template,
134+
host_key_fingerprint: if name == "pinned-identity" {
135+
"SHA256:public-identity".to_string()
136+
} else {
137+
String::new()
138+
},
134139
}
135140
}
136141

@@ -1291,6 +1296,25 @@ async fn get_sandbox_sends_name_and_maps_phase() {
12911296
assert_eq!(observed.as_deref(), Some("my-box"));
12921297
}
12931298

1299+
#[tokio::test]
1300+
async fn get_sandbox_preserves_host_fingerprint_and_accepts_older_gateways() {
1301+
let endpoint = start_mock(Arc::new(MockState::default())).await;
1302+
let client = connect(&endpoint).await;
1303+
let sandbox = client.get_sandbox("pinned-identity").await.unwrap();
1304+
assert_eq!(
1305+
sandbox.host_key_fingerprint.as_deref(),
1306+
Some("SHA256:public-identity")
1307+
);
1308+
assert!(
1309+
client
1310+
.get_sandbox("legacy")
1311+
.await
1312+
.unwrap()
1313+
.host_key_fingerprint
1314+
.is_none()
1315+
);
1316+
}
1317+
12941318
#[tokio::test]
12951319
async fn get_sandbox_preserves_workload_template_provenance() {
12961320
let state = Arc::new(MockState::default());

0 commit comments

Comments
 (0)