Skip to content

Commit 2d99ecf

Browse files
authored
fix(sandbox-backend): reauthenticate same-epoch bearer renewals (#3411)
Authenticate renewed Sandbox Protocol bearers on the existing connection before publishing their fingerprint. Capture the exact authorization header for Attach and Confirm, and preserve the connection and previous fingerprint when confirmation fails. Add regression coverage for repeated renewal, failed confirmation retries, and concurrent token-slot changes. Document that same-epoch renewal preserves pending streams while epoch changes still require an authenticated replacement connection. Signed-off-by: Shiju <shiju@nvidia.com>
1 parent a316fd7 commit 2d99ecf

3 files changed

Lines changed: 368 additions & 39 deletions

File tree

architecture/sandbox.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ credentials to claim the existing runtime generation. Confirmation resumes the
3939
workload; expiration terminates it. A credential replacement does not displace
4040
the active connection until the new connection is confirmed. Idle healthy
4141
connections remain usable.
42+
43+
A renewed Sandbox Protocol bearer is authenticated even when its credential epoch is unchanged. The supervisor confirms that bearer on the active physical connection and records its fingerprint only after confirmation succeeds, preserving pending streams and the mediation session. Changing the credential epoch still requires an authenticated replacement connection.
44+
4245
Unauthenticated TLS handshakes have a separate bounded asynchronous pool and
4346
five-second deadline, never consuming authenticated control slots or threads.
4447
The socket broker reserves the TCP control-listener port against workload

crates/openshell-sandbox-backend/src/runtime.rs

Lines changed: 118 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use openshell_isolation_interface::contract::{
2828
ReadyBoundary, RunningBoundary, SandboxContext, TcpOpenDecision, TcpOpenDenial,
2929
VerifiedBackendDescriptor,
3030
};
31+
use sha2::{Digest as _, Sha256};
3132
use tokio::io::{AsyncReadExt, AsyncWriteExt};
3233
#[cfg(unix)]
3334
use tokio::net::UnixStream;
@@ -975,10 +976,43 @@ struct BoundaryClient {
975976
#[derive(Clone)]
976977
struct CachedGrpcChannel {
977978
credential_epoch: openshell_core::jwt::CredentialEpoch,
979+
bearer_fingerprint: [u8; 32],
978980
generation: u64,
979981
channel: tonic::transport::Channel,
980982
}
981983

984+
// Cache only a digest; authenticate with the exact captured header so a
985+
// concurrent renewal cannot be recorded before its credential is sent.
986+
struct BoundaryCredential {
987+
epoch: openshell_core::jwt::CredentialEpoch,
988+
authorization: tonic::metadata::AsciiMetadataValue,
989+
fingerprint: [u8; 32],
990+
}
991+
992+
impl BoundaryCredential {
993+
fn capture(slot: &openshell_core::jwt::SessionBearerTokenSlot) -> Result<Self, BackendError> {
994+
loop {
995+
let epoch = slot.credential_epoch().ok_or_else(|| {
996+
BackendError::Unavailable("Sandbox Protocol credential unavailable".to_string())
997+
})?;
998+
let authorization = slot.authorization_metadata().map_err(|error| {
999+
BackendError::Unavailable(format!(
1000+
"Sandbox Protocol credential unavailable: {error}"
1001+
))
1002+
})?;
1003+
// Epochs are monotonic; retry if authorization crossed an epoch change.
1004+
if slot.credential_epoch() == Some(epoch) {
1005+
let fingerprint = Sha256::digest(authorization.as_encoded_bytes()).into();
1006+
return Ok(Self {
1007+
epoch,
1008+
authorization,
1009+
fingerprint,
1010+
});
1011+
}
1012+
}
1013+
}
1014+
}
1015+
9821016
impl BoundaryClient {
9831017
fn new(
9841018
runtime_descriptor: SandboxRuntimeDescriptor,
@@ -1199,27 +1233,62 @@ impl BoundaryClient {
11991233
}
12001234

12011235
async fn ensure_current_credential_connection(&self) -> Result<(), BackendError> {
1202-
let credential_epoch = self.sandbox_bearer.credential_epoch().ok_or_else(|| {
1203-
BackendError::Unavailable("Sandbox Protocol credential unavailable".to_string())
1204-
})?;
1236+
let credential = BoundaryCredential::capture(&self.sandbox_bearer)?;
12051237
if self
12061238
.grpc_channel
12071239
.lock()
12081240
.await
12091241
.as_ref()
1210-
.is_none_or(|cached| cached.credential_epoch == credential_epoch)
1242+
.is_none_or(|cached| {
1243+
cached.credential_epoch == credential.epoch
1244+
&& cached.bearer_fingerprint == credential.fingerprint
1245+
})
12111246
{
12121247
return Ok(());
12131248
}
1214-
12151249
let _reconnect = self.reconnect.lock().await;
1216-
if self
1217-
.grpc_channel
1250+
let credential = BoundaryCredential::capture(&self.sandbox_bearer)?;
1251+
let Some(cached) = self.grpc_channel.lock().await.clone() else {
1252+
return Ok(());
1253+
};
1254+
if cached.credential_epoch == credential.epoch
1255+
&& cached.bearer_fingerprint == credential.fingerprint
1256+
{
1257+
return Ok(());
1258+
}
1259+
let confirm = self
1260+
.confirm_request
12181261
.lock()
1262+
.unwrap_or_else(std::sync::PoisonError::into_inner)
1263+
.clone();
1264+
let Some(confirm) = confirm else {
1265+
// Initial attach/confirm authenticates before the monitor runs.
1266+
return if cached.credential_epoch == credential.epoch {
1267+
Ok(())
1268+
} else {
1269+
Err(BackendError::Unavailable(
1270+
"cannot rotate Sandbox Protocol connection before confirmation".to_string(),
1271+
))
1272+
};
1273+
};
1274+
if cached.credential_epoch == credential.epoch {
1275+
// Renewal preserves the authorization epoch, but the boundary's
1276+
// connection deadline advances only on a newly authenticated RPC.
1277+
// Reconfirm on this channel to preserve pending accepts and streams.
1278+
let response = tokio::time::timeout(
1279+
REQUEST_TIMEOUT,
1280+
self.exchange_on_channel(cached.channel.clone(), &confirm, &credential),
1281+
)
12191282
.await
1220-
.as_ref()
1221-
.is_some_and(|cached| cached.credential_epoch == credential_epoch)
1222-
{
1283+
.map_err(|_| {
1284+
BackendError::Unavailable("boundary credential renewal timed out".to_string())
1285+
})??;
1286+
expect_response(response, "confirmed")?;
1287+
if let Some(current) = self.grpc_channel.lock().await.as_mut()
1288+
&& current.generation == cached.generation
1289+
{
1290+
current.bearer_fingerprint = credential.fingerprint;
1291+
}
12231292
return Ok(());
12241293
}
12251294
let attach = self
@@ -1232,21 +1301,14 @@ impl BoundaryClient {
12321301
"cannot rotate Sandbox Protocol connection before attach".to_string(),
12331302
)
12341303
})?;
1235-
let confirm = self
1236-
.confirm_request
1237-
.lock()
1238-
.unwrap_or_else(std::sync::PoisonError::into_inner)
1239-
.clone()
1240-
.ok_or_else(|| {
1241-
BackendError::Unavailable(
1242-
"cannot rotate Sandbox Protocol connection before confirmation".to_string(),
1243-
)
1244-
})?;
12451304
let channel = self.build_grpc_channel().await?;
1246-
self.exchange_on_channel(channel.clone(), &attach).await?;
1247-
self.exchange_on_channel(channel.clone(), &confirm).await?;
1305+
self.exchange_on_channel(channel.clone(), &attach, &credential)
1306+
.await?;
1307+
self.exchange_on_channel(channel.clone(), &confirm, &credential)
1308+
.await?;
12481309
*self.grpc_channel.lock().await = Some(CachedGrpcChannel {
1249-
credential_epoch,
1310+
credential_epoch: credential.epoch,
1311+
bearer_fingerprint: credential.fingerprint,
12501312
generation: self
12511313
.next_connection_generation
12521314
.fetch_add(1, Ordering::Relaxed),
@@ -1292,16 +1354,17 @@ impl BoundaryClient {
12921354
.lock()
12931355
.unwrap_or_else(std::sync::PoisonError::into_inner)
12941356
.clone();
1295-
let credential_epoch = self.sandbox_bearer.credential_epoch().ok_or_else(|| {
1296-
BackendError::Unavailable("Sandbox Protocol credential unavailable".to_string())
1297-
})?;
1357+
let credential = BoundaryCredential::capture(&self.sandbox_bearer)?;
12981358
let channel = self.build_grpc_channel().await?;
1299-
self.exchange_on_channel(channel.clone(), &attach).await?;
1359+
self.exchange_on_channel(channel.clone(), &attach, &credential)
1360+
.await?;
13001361
if let Some(confirm) = confirm {
1301-
self.exchange_on_channel(channel.clone(), &confirm).await?;
1362+
self.exchange_on_channel(channel.clone(), &confirm, &credential)
1363+
.await?;
13021364
}
13031365
*self.grpc_channel.lock().await = Some(CachedGrpcChannel {
1304-
credential_epoch,
1366+
credential_epoch: credential.epoch,
1367+
bearer_fingerprint: credential.fingerprint,
13051368
generation: self
13061369
.next_connection_generation
13071370
.fetch_add(1, Ordering::Relaxed),
@@ -1332,11 +1395,15 @@ impl BoundaryClient {
13321395
&self,
13331396
channel: tonic::transport::Channel,
13341397
envelope: &RequestEnvelope,
1398+
credential: &BoundaryCredential,
13351399
) -> Result<Response, BackendError> {
13361400
let request_id = envelope.request_id.clone();
1337-
let mut stream =
1338-
open_grpc_client_stream(channel, GrpcStreamKind::Exchange, &self.sandbox_bearer)
1339-
.await?;
1401+
let mut stream = open_grpc_client_stream_with_authorization(
1402+
channel,
1403+
GrpcStreamKind::Exchange,
1404+
credential.authorization.clone(),
1405+
)
1406+
.await?;
13401407
let frame = encode_frame(envelope)
13411408
.map_err(|error| BackendError::Process(format!("encode control request: {error}")))?;
13421409
stream.write_all(&frame).await.map_err(|error| {
@@ -1444,12 +1511,11 @@ impl BoundaryClient {
14441511
if let Some(cached) = state.as_ref() {
14451512
return Ok(cached.channel.clone());
14461513
}
1447-
let credential_epoch = self.sandbox_bearer.credential_epoch().ok_or_else(|| {
1448-
BackendError::Unavailable("Sandbox Protocol credential unavailable".to_string())
1449-
})?;
1514+
let credential = BoundaryCredential::capture(&self.sandbox_bearer)?;
14501515
let channel = self.build_grpc_channel().await?;
14511516
*state = Some(CachedGrpcChannel {
1452-
credential_epoch,
1517+
credential_epoch: credential.epoch,
1518+
bearer_fingerprint: credential.fingerprint,
14531519
generation: self
14541520
.next_connection_generation
14551521
.fetch_add(1, Ordering::Relaxed),
@@ -1566,6 +1632,17 @@ async fn open_grpc_client_stream(
15661632
channel: tonic::transport::Channel,
15671633
kind: GrpcStreamKind,
15681634
sandbox_bearer: &openshell_core::jwt::SessionBearerTokenSlot,
1635+
) -> Result<BoundaryDuplexStream, BackendError> {
1636+
let authorization = sandbox_bearer.authorization_metadata().map_err(|error| {
1637+
BackendError::Unavailable(format!("Sandbox Protocol credential unavailable: {error}"))
1638+
})?;
1639+
open_grpc_client_stream_with_authorization(channel, kind, authorization).await
1640+
}
1641+
1642+
async fn open_grpc_client_stream_with_authorization(
1643+
channel: tonic::transport::Channel,
1644+
kind: GrpcStreamKind,
1645+
authorization: tonic::metadata::AsciiMetadataValue,
15691646
) -> Result<BoundaryDuplexStream, BackendError> {
15701647
let (application, bridge) = tokio::io::duplex(256 * 1024);
15711648
let (reader, writer) = tokio::io::split(bridge);
@@ -1575,9 +1652,6 @@ async fn open_grpc_client_stream(
15751652
.max_decoding_message_size(64 * 1024)
15761653
.max_encoding_message_size(64 * 1024);
15771654
let mut request = tonic::Request::new(ReceiverStream::new(outbound_rx));
1578-
let authorization = sandbox_bearer.authorization_metadata().map_err(|error| {
1579-
BackendError::Unavailable(format!("Sandbox Protocol credential unavailable: {error}"))
1580-
})?;
15811655
request
15821656
.metadata_mut()
15831657
.insert("authorization", authorization);
@@ -1754,6 +1828,8 @@ fn is_transport_unavailable(message: &str) -> bool {
17541828

17551829
#[cfg(test)]
17561830
mod tests {
1831+
mod credential_renewal;
1832+
17571833
use std::path::PathBuf;
17581834
use std::pin::Pin;
17591835
use std::task::{Context, Poll};
@@ -1980,6 +2056,9 @@ mod tests {
19802056
);
19812057
*client.grpc_channel.lock().await = Some(CachedGrpcChannel {
19822058
credential_epoch: openshell_core::jwt::CredentialEpoch::new(1).expect("test epoch"),
2059+
bearer_fingerprint: BoundaryCredential::capture(&client.sandbox_bearer)
2060+
.expect("test bearer")
2061+
.fingerprint,
19832062
generation: 1,
19842063
channel,
19852064
});

0 commit comments

Comments
 (0)