Skip to content

Commit 8ecb902

Browse files
committed
fix(auth): validate refreshed credentials before rotation
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 0769ba0 commit 8ecb902

3 files changed

Lines changed: 120 additions & 53 deletions

File tree

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

Lines changed: 110 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -85,16 +85,48 @@ struct AcquiredToken {
8585
}
8686

8787
fn install_token_slot(token: &str) -> Result<TokenSlot> {
88-
let bearer = AsciiMetadataValue::try_from(format!("Bearer {token}"))
88+
let bearer = validate_gateway_bearer(token)?;
89+
Ok(install_validated_token_slot(bearer))
90+
}
91+
92+
fn validate_gateway_bearer(token: &str) -> Result<AsciiMetadataValue> {
93+
AsciiMetadataValue::try_from(format!("Bearer {token}"))
8994
.into_diagnostic()
90-
.wrap_err("sandbox JWT contained characters not valid for a header value")?;
95+
.wrap_err("sandbox JWT contained characters not valid for a header value")
96+
}
97+
98+
fn install_validated_token_slot(bearer: AsciiMetadataValue) -> TokenSlot {
9199
if let Some(existing) = TOKEN_SLOT.get() {
92-
*existing.write().expect("token slot poisoned") = bearer;
93-
return Ok(existing.clone());
100+
*existing
101+
.write()
102+
.unwrap_or_else(std::sync::PoisonError::into_inner) = bearer;
103+
return existing.clone();
94104
}
95105
let slot: TokenSlot = Arc::new(RwLock::new(bearer));
96106
let _ = TOKEN_SLOT.set(slot.clone());
97-
Ok(TOKEN_SLOT.get().cloned().unwrap_or(slot))
107+
TOKEN_SLOT.get().cloned().unwrap_or(slot)
108+
}
109+
110+
#[cfg(feature = "jwt")]
111+
struct ValidatedSandboxRefresh {
112+
token: crate::jwt::SecretJwt,
113+
expires_at: i64,
114+
credential_epoch: crate::jwt::CredentialEpoch,
115+
}
116+
117+
#[cfg(feature = "jwt")]
118+
fn validate_sandbox_refresh(
119+
response: &crate::proto::RefreshSandboxTokenResponse,
120+
) -> std::result::Result<ValidatedSandboxRefresh, crate::jwt::SessionJwtError> {
121+
let token = crate::jwt::SecretJwt::parse(response.sandbox_token.clone())?;
122+
let credential_epoch = crate::jwt::CredentialEpoch::new(response.credential_epoch)?;
123+
let expires_at = response.sandbox_expires_at_ms / 1000;
124+
crate::jwt::SessionBearerTokenSlot::new(token.clone(), expires_at, credential_epoch)?;
125+
Ok(ValidatedSandboxRefresh {
126+
token,
127+
expires_at,
128+
credential_epoch,
129+
})
98130
}
99131

100132
/// Install the gateway-session credential supplied in trusted supervisor
@@ -380,28 +412,37 @@ async fn refresh_token_loop(
380412
{
381413
Ok(resp) => {
382414
let response = resp.into_inner();
383-
let new_token = response.token;
384-
match AsciiMetadataValue::try_from(format!("Bearer {new_token}")) {
385-
Ok(value) => {
386-
if let Ok(mut guard) = slot.write() {
387-
*guard = value;
388-
info!("renewed gateway sandbox JWT in-place");
389-
}
390-
}
391-
Err(e) => warn!(error = %e, "refreshed JWT contained invalid header bytes"),
392-
}
393415
#[cfg(feature = "jwt")]
394-
if let Some(sandbox_slot) = SANDBOX_BEARER_SLOT.get() {
395-
let update =
396-
crate::jwt::SecretJwt::parse(response.sandbox_token).and_then(|token| {
397-
let epoch =
398-
crate::jwt::CredentialEpoch::new(response.credential_epoch)?;
399-
sandbox_slot.update(token, response.sandbox_expires_at_ms / 1000, epoch)
400-
});
401-
if let Err(error) = update {
416+
let sandbox_refresh = match validate_sandbox_refresh(&response) {
417+
Ok(refresh) => refresh,
418+
Err(error) => {
402419
warn!(%error, "gateway returned an invalid Sandbox Protocol credential");
420+
continue;
421+
}
422+
};
423+
let gateway_bearer = match validate_gateway_bearer(&response.token) {
424+
Ok(value) => value,
425+
Err(error) => {
426+
warn!(%error, "refreshed JWT contained invalid header bytes");
427+
continue;
403428
}
429+
};
430+
431+
*slot
432+
.write()
433+
.unwrap_or_else(std::sync::PoisonError::into_inner) = gateway_bearer;
434+
#[cfg(feature = "jwt")]
435+
if let Some(sandbox_slot) = SANDBOX_BEARER_SLOT.get()
436+
&& let Err(error) = sandbox_slot.update(
437+
sandbox_refresh.token,
438+
sandbox_refresh.expires_at,
439+
sandbox_refresh.credential_epoch,
440+
)
441+
&& error != crate::jwt::SessionJwtError::StaleCredentialEpoch
442+
{
443+
warn!(%error, "gateway returned an invalid Sandbox Protocol credential");
404444
}
445+
info!("renewed gateway and Sandbox Protocol credentials in-place");
405446
}
406447
Err(status) => {
407448
if status.code() == tonic::Code::Unauthenticated
@@ -494,28 +535,11 @@ async fn refresh_extension_credentials_with_client(
494535
.wrap_err("failed to refresh extension service credentials")?
495536
.into_inner();
496537

497-
// The same refresh response renews the gateway credential. Install it
498-
// before returning so all process-wide gateway clients stay current. This
499-
// is a superset of what the dedicated renewal loop would do, so letting it
500-
// land early is harmless.
501-
install_token_slot(&response.token)?;
538+
let gateway_bearer = validate_gateway_bearer(&response.token)?;
502539
#[cfg(feature = "jwt")]
503-
if let Some(sandbox_slot) = SANDBOX_BEARER_SLOT.get() {
504-
let token = crate::jwt::SecretJwt::parse(response.sandbox_token.clone())
505-
.into_diagnostic()
506-
.wrap_err("gateway returned an invalid Sandbox Protocol token")?;
507-
let epoch = crate::jwt::CredentialEpoch::new(response.credential_epoch)
508-
.into_diagnostic()
509-
.wrap_err("gateway returned an invalid Sandbox Protocol credential epoch")?;
510-
match sandbox_slot.update(token, response.sandbox_expires_at_ms / 1000, epoch) {
511-
Ok(()) | Err(crate::jwt::SessionJwtError::StaleCredentialEpoch) => {}
512-
Err(error) => {
513-
return Err(miette::miette!(
514-
"gateway returned an invalid Sandbox Protocol credential: {error}"
515-
));
516-
}
517-
}
518-
}
540+
let sandbox_refresh = validate_sandbox_refresh(&response)
541+
.into_diagnostic()
542+
.wrap_err("gateway returned an invalid Sandbox Protocol credential")?;
519543

520544
// Validate the whole response before mutating any slot, so a malformed or
521545
// partial reply cannot leave the store half-rotated.
@@ -544,6 +568,32 @@ async fn refresh_extension_credentials_with_client(
544568
}
545569

546570
let now_ms = now_ms();
571+
for (token, expires_at_ms) in validated.values() {
572+
BearerTokenSlot::new(token, *expires_at_ms)
573+
.into_diagnostic()
574+
.wrap_err("gateway returned an invalid extension credential")?;
575+
}
576+
577+
// Commit only after the gateway, Sandbox Protocol, and extension
578+
// credentials have all been parsed and validated. The remaining updates
579+
// repeat those validations but cannot fail for the validated inputs.
580+
install_validated_token_slot(gateway_bearer);
581+
#[cfg(feature = "jwt")]
582+
if let Some(sandbox_slot) = SANDBOX_BEARER_SLOT.get() {
583+
match sandbox_slot.update(
584+
sandbox_refresh.token,
585+
sandbox_refresh.expires_at,
586+
sandbox_refresh.credential_epoch,
587+
) {
588+
Ok(()) | Err(crate::jwt::SessionJwtError::StaleCredentialEpoch) => {}
589+
Err(error) => {
590+
return Err(miette::miette!(
591+
"validated Sandbox Protocol credential could not be installed: {error}"
592+
));
593+
}
594+
}
595+
}
596+
547597
let mut selected = HashMap::with_capacity(validated.len());
548598
for (name, (token, expires_at_ms)) in validated {
549599
let slot = store
@@ -603,6 +653,22 @@ fn parse_jwt_exp_ms(jwt: &str) -> Option<i64> {
603653
mod auth_tests {
604654
use super::*;
605655

656+
#[cfg(feature = "jwt")]
657+
#[test]
658+
fn sandbox_refresh_validation_rejects_invalid_lifetime_before_installation() {
659+
let response = crate::proto::RefreshSandboxTokenResponse {
660+
sandbox_token: "sandbox-token".to_string(),
661+
sandbox_expires_at_ms: 999,
662+
credential_epoch: 2,
663+
..Default::default()
664+
};
665+
666+
assert_eq!(
667+
validate_sandbox_refresh(&response).err(),
668+
Some(crate::jwt::SessionJwtError::InvalidLifetime)
669+
);
670+
}
671+
606672
#[test]
607673
fn parse_jwt_exp_reads_unsigned_payload() {
608674
use base64::Engine as _;

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result<rustls::ClientConfi
224224
}
225225
let mut config =
226226
rustls::ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
227-
.with_root_certificates(roots)
228-
.with_no_client_auth();
227+
.with_root_certificates(roots)
228+
.with_no_client_auth();
229229
config.alpn_protocols = vec![b"h2".to_vec()];
230230
Ok(config)
231231
}
@@ -1909,9 +1909,9 @@ mod tests {
19091909
.expect("server private key");
19101910
let mut server_config =
19111911
rustls::ServerConfig::builder_with_protocol_versions(protocol_versions)
1912-
.with_no_client_auth()
1913-
.with_single_cert(certificates, private_key)
1914-
.expect("build test TLS server config");
1912+
.with_no_client_auth()
1913+
.with_single_cert(certificates, private_key)
1914+
.expect("build test TLS server config");
19151915
server_config.alpn_protocols = vec![b"h2".to_vec()];
19161916
TestCertificate {
19171917
client_tls: SandboxTlsClientConfig {

‎crates/openshell-sandbox/src/boundary_server.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2772,10 +2772,11 @@ mod linux {
27722772
"boundary TLS private-key file contains no private key",
27732773
)
27742774
})?;
2775-
let mut config = rustls::ServerConfig::builder()
2776-
.with_no_client_auth()
2777-
.with_single_cert(certificates, private_key)
2778-
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
2775+
let mut config =
2776+
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
2777+
.with_no_client_auth()
2778+
.with_single_cert(certificates, private_key)
2779+
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
27792780
config.alpn_protocols = vec![b"h2".to_vec()];
27802781
for path in [&tls.certificate_chain_path, &tls.private_key_path] {
27812782
std::fs::remove_file(path)?;

0 commit comments

Comments
 (0)