Skip to content

Commit 481fe98

Browse files
committed
fix(security): close credential and TLS replay paths
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 73e4d5c commit 481fe98

10 files changed

Lines changed: 484 additions & 45 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,10 @@ controlling Sandbox CR. The bootstrap path accepts
276276
both `agents.x-k8s.io/v1beta1` ownerReferences from newer Agent Sandbox
277277
controllers and `agents.x-k8s.io/v1alpha1` ownerReferences from existing
278278
deployments. Supervisors renew gateway JWTs in memory before expiry only while
279-
the sandbox record still exists. Older tokens are not server-revoked; shared
280-
deployments bound replay exposure with short `gateway_jwt.ttl_secs` lifetimes.
279+
the sandbox record still exists. Each successful refresh atomically stores the
280+
new gateway-token ID in that sandbox record, invalidating the consumed bearer
281+
across every gateway replica. Short `gateway_jwt.ttl_secs` lifetimes still bound
282+
the exposure of a current bearer that has not yet been refreshed.
281283
Omitting `gateway_jwt.ttl_secs` selects non-expiring tokens for local
282284
single-player Docker, Podman, and VM gateways; those tokens carry `exp = 0`.
283285
Kubernetes and other shared deployments should set a positive TTL. Explicit

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -528,10 +528,24 @@ mod session {
528528
pub fn mint_pair(
529529
&self,
530530
identity: &SandboxRuntimeIdentity,
531+
) -> Result<MintedSessionTokenPair, SessionJwtError> {
532+
self.mint_pair_with_gateway_token_id(identity, Uuid::new_v4())
533+
}
534+
535+
/// Mint a token pair whose gateway-facing credential has a caller-owned
536+
/// lineage identifier.
537+
///
538+
/// The gateway persists this identifier before returning the token so
539+
/// refresh can reject every superseded bearer across all replicas. The
540+
/// Sandbox Protocol credential remains independently identified.
541+
pub fn mint_pair_with_gateway_token_id(
542+
&self,
543+
identity: &SandboxRuntimeIdentity,
544+
gateway_token_id: Uuid,
531545
) -> Result<MintedSessionTokenPair, SessionJwtError> {
532546
Ok(MintedSessionTokenPair {
533-
gateway: self.mint(SessionTokenProfile::Gateway, identity)?,
534-
sandbox: self.mint(SessionTokenProfile::Sandbox, identity)?,
547+
gateway: self.mint(SessionTokenProfile::Gateway, identity, gateway_token_id)?,
548+
sandbox: self.mint(SessionTokenProfile::Sandbox, identity, Uuid::new_v4())?,
535549
auth_epoch: identity.auth_epoch,
536550
})
537551
}
@@ -540,12 +554,12 @@ mod session {
540554
&self,
541555
profile: SessionTokenProfile,
542556
identity: &SandboxRuntimeIdentity,
557+
token_id: Uuid,
543558
) -> Result<MintedSessionToken, SessionJwtError> {
544559
let issued_at = self.clock.now_unix_seconds();
545560
let expires_at = issued_at.saturating_add(
546561
i64::try_from(self.ttl.as_secs()).map_err(|_| SessionJwtError::InvalidLifetime)?,
547562
);
548-
let token_id = Uuid::new_v4();
549563
let claims = SessionClaims {
550564
iss: self.issuer.clone(),
551565
sub: format!("{SANDBOX_SUBJECT_PREFIX}{}", identity.sandbox_id),

‎crates/openshell-server/src/auth/sandbox_jwt.rs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ impl SandboxSessionJwtAuthority {
185185
sandbox_id,
186186
identity.runtime_generation.clone(),
187187
identity.auth_epoch,
188+
identity.gateway_token_id,
188189
)
189190
}
190191

@@ -194,17 +195,21 @@ impl SandboxSessionJwtAuthority {
194195
sandbox_id: &str,
195196
runtime_generation: SandboxGenerationId,
196197
auth_epoch: CredentialEpoch,
198+
gateway_token_id: uuid::Uuid,
197199
) -> Result<SandboxLaunchAuthentication, Status> {
198200
let identity = SandboxRuntimeIdentity {
199201
sandbox_id: SandboxId::parse(sandbox_id)
200202
.map_err(|_| Status::invalid_argument("sandbox ID is invalid"))?,
201203
runtime_generation: runtime_generation.clone(),
202204
auth_epoch,
203205
};
204-
let pair = self.issuer.mint_pair(&identity).map_err(|error| {
205-
warn!(%error, "failed to mint launch-scoped sandbox credentials");
206-
Status::internal("failed to mint sandbox launch credentials")
207-
})?;
206+
let pair = self
207+
.issuer
208+
.mint_pair_with_gateway_token_id(&identity, gateway_token_id)
209+
.map_err(|error| {
210+
warn!(%error, "failed to mint launch-scoped sandbox credentials");
211+
Status::internal("failed to mint sandbox launch credentials")
212+
})?;
208213
Ok(SandboxLaunchAuthentication {
209214
supervisor: SupervisorAuthBundle {
210215
session_id: SandboxSessionId::new(),

‎crates/openshell-server/src/auth/sandbox_session.rs‎

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,29 @@ use openshell_core::jwt::{AuthenticatedSandboxSession, CredentialEpoch, SessionJ
99
use openshell_core::proto::{Sandbox, SandboxPhase};
1010
use openshell_core::sandbox_generation::SandboxGenerationId;
1111
use tonic::Status;
12+
use uuid::Uuid;
1213

1314
use crate::persistence::Store;
1415

1516
pub const RUNTIME_GENERATION_ANNOTATION: &str = "internal.openshell.ai/runtime-generation";
1617
pub const AUTH_EPOCH_ANNOTATION: &str = "internal.openshell.ai/auth-epoch";
18+
pub const GATEWAY_TOKEN_ID_ANNOTATION: &str = "internal.openshell.ai/gateway-token-id";
1719

1820
/// The complete durable authorization identity for one sandbox runtime.
1921
#[derive(Clone, Debug, PartialEq, Eq)]
2022
pub struct PersistedSandboxIdentity {
2123
pub runtime_generation: SandboxGenerationId,
2224
pub auth_epoch: CredentialEpoch,
25+
pub gateway_token_id: Uuid,
2326
}
2427

2528
impl PersistedSandboxIdentity {
2629
pub fn new() -> Result<Self, SessionJwtError> {
2730
Ok(Self {
28-
runtime_generation: SandboxGenerationId::parse(uuid::Uuid::new_v4().to_string())
31+
runtime_generation: SandboxGenerationId::parse(Uuid::new_v4().to_string())
2932
.map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?,
3033
auth_epoch: CredentialEpoch::new(1)?,
34+
gateway_token_id: Uuid::new_v4(),
3135
})
3236
}
3337

@@ -45,9 +49,14 @@ impl PersistedSandboxIdentity {
4549
.parse::<u64>()
4650
.map_err(|_| SessionJwtError::InvalidCredentialEpoch)
4751
.and_then(CredentialEpoch::new)?;
52+
let gateway_token_id = annotations
53+
.get(GATEWAY_TOKEN_ID_ANNOTATION)
54+
.ok_or(SessionJwtError::MissingRuntimeIdentity)
55+
.and_then(|value| Uuid::parse_str(value).map_err(|_| SessionJwtError::InvalidJti))?;
4856
Ok(Self {
4957
runtime_generation,
5058
auth_epoch,
59+
gateway_token_id,
5160
})
5261
}
5362

@@ -60,18 +69,32 @@ impl PersistedSandboxIdentity {
6069
AUTH_EPOCH_ANNOTATION.to_string(),
6170
self.auth_epoch.get().to_string(),
6271
);
72+
annotations.insert(
73+
GATEWAY_TOKEN_ID_ANNOTATION.to_string(),
74+
self.gateway_token_id.to_string(),
75+
);
76+
}
77+
78+
#[must_use]
79+
pub fn next_gateway_token(&self) -> Self {
80+
Self {
81+
runtime_generation: self.runtime_generation.clone(),
82+
auth_epoch: self.auth_epoch,
83+
gateway_token_id: Uuid::new_v4(),
84+
}
6385
}
6486
}
6587

6688
/// Load the authoritative runtime identity and compare it with a signed JWT.
6789
///
68-
/// Every gateway replica performs this check against shared persistence. No
69-
/// raw token, token ID, or refresh lineage needs to be replicated.
90+
/// Every gateway replica checks the persisted gateway-token ID in addition to
91+
/// the runtime generation and authorization epoch. Refresh replaces that ID
92+
/// with CAS, so an older bearer cannot rejoin the active credential lineage.
7093
#[allow(clippy::result_large_err)]
71-
pub async fn authorize_persisted(
94+
async fn load_authorized(
7295
store: &Store,
7396
principal: &AuthenticatedSandboxSession,
74-
) -> Result<PersistedSandboxIdentity, Status> {
97+
) -> Result<(Sandbox, PersistedSandboxIdentity), Status> {
7598
let sandbox = store
7699
.get_message::<Sandbox>(principal.sandbox_id.as_str())
77100
.await
@@ -98,14 +121,69 @@ pub async fn authorize_persisted(
98121
.ok_or_else(|| Status::unauthenticated("sandbox identity metadata is missing"))?;
99122
let identity = PersistedSandboxIdentity::read(&metadata.annotations)
100123
.map_err(|_| Status::unauthenticated("sandbox runtime identity is invalid"))?;
101-
if principal.runtime_generation != identity.runtime_generation
102-
|| principal.auth_epoch != identity.auth_epoch
103-
{
124+
let presented = (
125+
&principal.runtime_generation,
126+
principal.auth_epoch,
127+
principal.token_id,
128+
);
129+
let expected = (
130+
&identity.runtime_generation,
131+
identity.auth_epoch,
132+
identity.gateway_token_id,
133+
);
134+
if presented != expected {
104135
return Err(Status::unauthenticated(
105136
"gateway token does not match the active sandbox identity",
106137
));
107138
}
108-
Ok(identity)
139+
Ok((sandbox, identity))
140+
}
141+
142+
#[allow(clippy::result_large_err)]
143+
pub async fn authorize_persisted(
144+
store: &Store,
145+
principal: &AuthenticatedSandboxSession,
146+
) -> Result<PersistedSandboxIdentity, Status> {
147+
load_authorized(store, principal)
148+
.await
149+
.map(|(_, identity)| identity)
150+
}
151+
152+
/// Atomically consume the presented gateway bearer and install its successor.
153+
///
154+
/// A concurrent refresh or unrelated sandbox mutation causes the CAS to fail;
155+
/// callers retry from freshly authenticated state. The old token is never
156+
/// accepted after a successful update on any gateway replica.
157+
#[allow(clippy::result_large_err)]
158+
pub async fn rotate_gateway_token(
159+
store: &Store,
160+
principal: &AuthenticatedSandboxSession,
161+
next: &PersistedSandboxIdentity,
162+
) -> Result<PersistedSandboxIdentity, Status> {
163+
let (sandbox, current) = load_authorized(store, principal).await?;
164+
if next.runtime_generation != current.runtime_generation
165+
|| next.auth_epoch != current.auth_epoch
166+
|| next.gateway_token_id == current.gateway_token_id
167+
{
168+
return Err(Status::internal("gateway token successor is invalid"));
169+
}
170+
let metadata = sandbox
171+
.metadata
172+
.as_ref()
173+
.ok_or_else(|| Status::unauthenticated("sandbox identity metadata is missing"))?;
174+
store
175+
.update_message_cas::<Sandbox, _>(
176+
principal.sandbox_id.as_str(),
177+
metadata.resource_version,
178+
|sandbox| {
179+
if let Some(metadata) = sandbox.metadata.as_mut() {
180+
next.write(&mut metadata.annotations);
181+
}
182+
},
183+
)
184+
.await
185+
.map_err(|error| Status::aborted(format!("rotate gateway token: {error}")))?;
186+
Ok(next.clone())
109187
}
110188

111189
#[cfg(test)]
@@ -121,6 +199,7 @@ mod tests {
121199
runtime_generation: SandboxGenerationId::parse("generation-a")
122200
.expect("runtime generation"),
123201
auth_epoch: CredentialEpoch::new(1).expect("auth epoch"),
202+
gateway_token_id: Uuid::from_u128(1),
124203
};
125204
let mut metadata = ObjectMeta {
126205
id: "sandbox-a".to_string(),
@@ -140,13 +219,13 @@ mod tests {
140219
store.put_message(&sandbox).await.expect("persist sandbox");
141220
}
142221

143-
fn principal(auth_epoch: u64) -> AuthenticatedSandboxSession {
222+
fn principal(auth_epoch: u64, token_id: Uuid) -> AuthenticatedSandboxSession {
144223
AuthenticatedSandboxSession {
145224
sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"),
146225
runtime_generation: SandboxGenerationId::parse("generation-a")
147226
.expect("runtime generation"),
148227
auth_epoch: CredentialEpoch::new(auth_epoch).expect("auth epoch"),
149-
token_id: Uuid::new_v4(),
228+
token_id,
150229
issued_at: 1,
151230
expires_at: 2,
152231
}
@@ -157,30 +236,36 @@ mod tests {
157236
let store = Store::connect("sqlite::memory:").await.expect("store");
158237
persist_sandbox(&store, SandboxPhase::Ready).await;
159238

160-
authorize_persisted(&store, &principal(1))
239+
authorize_persisted(&store, &principal(1, Uuid::from_u128(1)))
161240
.await
162241
.expect("first replica authorizes from persistence");
163-
authorize_persisted(&store, &principal(1))
242+
authorize_persisted(&store, &principal(1, Uuid::from_u128(1)))
164243
.await
165244
.expect("second replica authorizes without local state");
166245

246+
let error = authorize_persisted(&store, &principal(1, Uuid::from_u128(2)))
247+
.await
248+
.expect_err("a different token lineage must be rejected");
249+
assert_eq!(error.code(), tonic::Code::Unauthenticated);
250+
167251
store
168252
.update_message_cas::<Sandbox, _>("sandbox-a", 0, |sandbox| {
169253
let next = PersistedSandboxIdentity {
170254
runtime_generation: SandboxGenerationId::parse("generation-a")
171255
.expect("runtime generation"),
172256
auth_epoch: CredentialEpoch::new(2).expect("auth epoch"),
257+
gateway_token_id: Uuid::from_u128(2),
173258
};
174259
next.write(&mut sandbox.metadata.as_mut().expect("metadata").annotations);
175260
})
176261
.await
177262
.expect("advance auth epoch");
178263

179-
let error = authorize_persisted(&store, &principal(1))
264+
let error = authorize_persisted(&store, &principal(1, Uuid::from_u128(1)))
180265
.await
181266
.expect_err("old epoch must be revoked on every replica");
182267
assert_eq!(error.code(), tonic::Code::Unauthenticated);
183-
authorize_persisted(&store, &principal(2))
268+
authorize_persisted(&store, &principal(2, Uuid::from_u128(2)))
184269
.await
185270
.expect("new epoch is active");
186271

@@ -190,7 +275,7 @@ mod tests {
190275
})
191276
.await
192277
.expect("stop sandbox");
193-
let error = authorize_persisted(&store, &principal(2))
278+
let error = authorize_persisted(&store, &principal(2, Uuid::from_u128(2)))
194279
.await
195280
.expect_err("stopped runtime must reject its token");
196281
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
@@ -202,9 +287,34 @@ mod tests {
202287
let store = Store::connect("sqlite::memory:").await.expect("store");
203288
persist_sandbox(&store, phase).await;
204289

205-
authorize_persisted(&store, &principal(1))
290+
authorize_persisted(&store, &principal(1, Uuid::from_u128(1)))
206291
.await
207292
.expect("terminal runtime can finish delivering exit state");
208293
}
209294
}
295+
296+
#[tokio::test]
297+
async fn rotating_gateway_token_revokes_its_predecessor() {
298+
let store = Store::connect("sqlite::memory:").await.expect("store");
299+
persist_sandbox(&store, SandboxPhase::Ready).await;
300+
let first = principal(1, Uuid::from_u128(1));
301+
302+
let expected = PersistedSandboxIdentity {
303+
runtime_generation: first.runtime_generation.clone(),
304+
auth_epoch: first.auth_epoch,
305+
gateway_token_id: Uuid::from_u128(2),
306+
};
307+
let next = rotate_gateway_token(&store, &first, &expected)
308+
.await
309+
.expect("rotate current gateway token");
310+
assert_ne!(next.gateway_token_id, first.token_id);
311+
312+
let error = authorize_persisted(&store, &first)
313+
.await
314+
.expect_err("consumed gateway token must be rejected");
315+
assert_eq!(error.code(), tonic::Code::Unauthenticated);
316+
authorize_persisted(&store, &principal(1, next.gateway_token_id))
317+
.await
318+
.expect("successor gateway token is current");
319+
}
210320
}

‎crates/openshell-server/src/compute/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6082,6 +6082,7 @@ mod tests {
60826082
)
60836083
.expect("test runtime generation"),
60846084
auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).expect("test auth epoch"),
6085+
gateway_token_id: uuid::Uuid::new_v4(),
60856086
}
60866087
.write(&mut annotations);
60876088
let mut sandbox = Sandbox {

0 commit comments

Comments
 (0)