@@ -28,6 +28,7 @@ use openshell_isolation_interface::contract::{
2828 ReadyBoundary , RunningBoundary , SandboxContext , TcpOpenDecision , TcpOpenDenial ,
2929 VerifiedBackendDescriptor ,
3030} ;
31+ use sha2:: { Digest as _, Sha256 } ;
3132use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
3233#[ cfg( unix) ]
3334use tokio:: net:: UnixStream ;
@@ -975,10 +976,43 @@ struct BoundaryClient {
975976#[ derive( Clone ) ]
976977struct 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+
9821016impl 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) ]
17561830mod 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