mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-09 09:15:23 +08:00
* fix(security): authenticate extension services Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * feat(extension-core): verify gateway JWTs Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * refactor(extension-core): keep inbound verification external This should become an extension SDK package. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(security): harden the extension authentication contract Follow-up hardening on the alpha extension authentication mechanism. Claim contract: - Extension tokens carry an explicit `typ` of `openshell-ext+jwt`. They share a signing key with sandbox-to-gateway admission tokens and were otherwise separated by audience alone, so a verifier that neglects to check `aud` could accept a gateway credential. The header is a second, independent discriminator. - Publish OIDC-shaped discovery at `/.well-known/openid-configuration` so a service configured with only the gateway URL can learn the exact expected issuer and the JWKS location. It is shaped, not compliant: `issuer` is the gateway identity, not the serving URL. Audience agreement: - `MiddlewareManifest` and `InterceptorManifest` gain `expected_audience`. The audience is otherwise configured independently on each side of the boundary, where a mismatch surfaces only as an opaque authentication failure on every call. OpenShell now compares the two and fails at startup. An empty field keeps the check off for existing services. Compatibility: - Add `allow_insecure_transport` per registration. Enabling gateway JWT signing previously made any plaintext endpoint a hard startup failure, including the endpoint form used in our own documentation. The opt-out attaches no credential, is refused by the gateway if a supervisor asks for one, and warns at every startup. - Make the transport requirement kind-aware. A middleware endpoint must be reachable from every sandbox supervisor, so only interceptors may use a gateway-local Unix socket. Credential lifecycle: - Replace the process-global slot map with a supervisor-owned `ExtensionCredentialStore` shared explicitly across the gateway connections the supervisor opens, removing test-order coupling. - Rotate only when a credential is missing or has passed four fifths of its lifetime. Configuration polling ran every ten seconds against fifteen-minute credentials, so each poll re-ran gateway effective-policy resolution and re-minted the gateway token. - Bound credential minting per sandbox, since each request resolves the caller's effective policy. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * docs: record alpha extension authentication in RFC appendices Restore the RFC 0009 and 0010 bodies to their accepted text and move every extension-authentication update into appendices instead. An RFC records a decision at a point in time; superseding detail belongs alongside it rather than rewritten into it. RFC 0009's appendix carries the shared contract: claims, authorization, key distribution, the `allow_insecure_transport` replacement for the body's `allow_insecure`, and residual risks. RFC 0010's records only what differs for interceptors and links to it. The existing protocol-extensions appendix, which parked the phase 2 transport question, now points forward to what was built. Also document the audience handshake, the discovery endpoint, the `typ` requirement, and `jti` replay guidance in the extensibility and gateway configuration pages, and correct the middleware transport guidance: middleware endpoints must be reachable from sandbox supervisors, so Unix sockets are not an option there. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * refactor(extension-core): abstract extension server trust Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * docs(core): update middleware manifest example Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(extension-auth): preserve unsigned gateway compatibility Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(extension-auth): reject cross-domain token replay Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> --------- Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
253 lines
7.5 KiB
Rust
253 lines
7.5 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use std::fmt;
|
|
use std::sync::{Arc, RwLock};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use tonic::metadata::AsciiMetadataValue;
|
|
use tonic::{Request, Status};
|
|
|
|
#[derive(Clone)]
|
|
pub struct BearerTokenSlot {
|
|
inner: Arc<RwLock<Option<Token>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Token {
|
|
authorization: AsciiMetadataValue,
|
|
expires_at_ms: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum TokenSlotError {
|
|
#[error("extension bearer token is not valid for an HTTP authorization header")]
|
|
InvalidToken,
|
|
#[error("extension bearer token expiry must be a positive Unix timestamp in milliseconds")]
|
|
InvalidExpiry,
|
|
}
|
|
|
|
impl BearerTokenSlot {
|
|
/// Create an empty slot. Requests fail closed until [`Self::update`] is called.
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
inner: Arc::new(RwLock::new(None)),
|
|
}
|
|
}
|
|
|
|
pub fn new(token: &str, expires_at_ms: i64) -> Result<Self, TokenSlotError> {
|
|
let slot = Self::empty();
|
|
slot.update(token, expires_at_ms)?;
|
|
Ok(slot)
|
|
}
|
|
|
|
/// Replace the credential without rebuilding channels or generated clients.
|
|
pub fn update(&self, token: &str, expires_at_ms: i64) -> Result<(), TokenSlotError> {
|
|
if expires_at_ms <= 0 {
|
|
return Err(TokenSlotError::InvalidExpiry);
|
|
}
|
|
if token.is_empty() || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
|
|
return Err(TokenSlotError::InvalidToken);
|
|
}
|
|
let authorization = AsciiMetadataValue::try_from(format!("Bearer {token}"))
|
|
.map_err(|_| TokenSlotError::InvalidToken)?;
|
|
*self
|
|
.inner
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Token {
|
|
authorization,
|
|
expires_at_ms,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove the credential immediately. Subsequent requests fail closed.
|
|
pub fn clear(&self) {
|
|
*self
|
|
.inner
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
|
}
|
|
|
|
pub fn expires_at_ms(&self) -> Option<i64> {
|
|
self.inner
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.as_ref()
|
|
.map(|token| token.expires_at_ms)
|
|
}
|
|
|
|
pub fn interceptor(&self) -> BearerTokenInterceptor {
|
|
BearerTokenInterceptor {
|
|
slot: Some(self.clone()),
|
|
}
|
|
}
|
|
|
|
fn authorization_at(&self, now_ms: i64) -> Result<AsciiMetadataValue, Status> {
|
|
let guard = self
|
|
.inner
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let token = guard
|
|
.as_ref()
|
|
.ok_or_else(|| Status::unauthenticated("extension bearer token is unavailable"))?;
|
|
if token.expires_at_ms <= now_ms {
|
|
return Err(Status::unauthenticated(
|
|
"extension bearer token has expired",
|
|
));
|
|
}
|
|
Ok(token.authorization.clone())
|
|
}
|
|
}
|
|
|
|
impl Default for BearerTokenSlot {
|
|
fn default() -> Self {
|
|
Self::empty()
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for BearerTokenSlot {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("BearerTokenSlot")
|
|
.field("expires_at_ms", &self.expires_at_ms())
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct BearerTokenInterceptor {
|
|
slot: Option<BearerTokenSlot>,
|
|
}
|
|
|
|
impl BearerTokenInterceptor {
|
|
/// Create an explicit no-op interceptor for legacy registrations that have
|
|
/// not opted into audience authentication.
|
|
pub const fn disabled() -> Self {
|
|
Self { slot: None }
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for BearerTokenInterceptor {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("BearerTokenInterceptor")
|
|
.field("enabled", &self.slot.is_some())
|
|
.field("slot", &self.slot)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl tonic::service::Interceptor for BearerTokenInterceptor {
|
|
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
|
|
let Some(slot) = &self.slot else {
|
|
return Ok(request);
|
|
};
|
|
let now_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_or(0, |duration| {
|
|
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
|
|
});
|
|
let authorization = slot.authorization_at(now_ms)?;
|
|
request
|
|
.metadata_mut()
|
|
.insert("authorization", authorization);
|
|
Ok(request)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use tonic::service::Interceptor;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn slot_rotates_all_interceptor_clones_in_place() {
|
|
let slot = BearerTokenSlot::new("first-secret", i64::MAX).unwrap();
|
|
let mut first = slot.interceptor();
|
|
let mut second = first.clone();
|
|
|
|
assert_eq!(
|
|
first
|
|
.call(Request::new(()))
|
|
.unwrap()
|
|
.metadata()
|
|
.get("authorization")
|
|
.unwrap(),
|
|
"Bearer first-secret"
|
|
);
|
|
slot.update("second-secret", i64::MAX).unwrap();
|
|
assert_eq!(
|
|
second
|
|
.call(Request::new(()))
|
|
.unwrap()
|
|
.metadata()
|
|
.get("authorization")
|
|
.unwrap(),
|
|
"Bearer second-secret"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_expired_and_cleared_slots_fail_closed() {
|
|
let slot = BearerTokenSlot::empty();
|
|
assert_eq!(
|
|
slot.interceptor()
|
|
.call(Request::new(()))
|
|
.unwrap_err()
|
|
.code(),
|
|
tonic::Code::Unauthenticated
|
|
);
|
|
|
|
slot.update("expired", 1).unwrap();
|
|
assert_eq!(
|
|
slot.interceptor()
|
|
.call(Request::new(()))
|
|
.unwrap_err()
|
|
.code(),
|
|
tonic::Code::Unauthenticated
|
|
);
|
|
|
|
slot.update("current", i64::MAX).unwrap();
|
|
slot.clear();
|
|
assert_eq!(
|
|
slot.interceptor()
|
|
.call(Request::new(()))
|
|
.unwrap_err()
|
|
.code(),
|
|
tonic::Code::Unauthenticated
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn debug_and_errors_do_not_expose_token_material() {
|
|
let secret = "super-secret-extension-token";
|
|
let slot = BearerTokenSlot::new(secret, i64::MAX).unwrap();
|
|
assert!(!format!("{slot:?}").contains(secret));
|
|
assert!(!format!("{:?}", slot.interceptor()).contains(secret));
|
|
|
|
let error = BearerTokenSlot::new("contains\nnewline", i64::MAX).unwrap_err();
|
|
assert!(!error.to_string().contains("contains"));
|
|
assert_eq!(
|
|
BearerTokenSlot::new("", i64::MAX).unwrap_err(),
|
|
TokenSlotError::InvalidToken
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_interceptor_leaves_authorization_untouched() {
|
|
let mut interceptor = BearerTokenInterceptor::disabled();
|
|
let mut request = Request::new(());
|
|
request
|
|
.metadata_mut()
|
|
.insert("authorization", "Bearer caller-value".parse().unwrap());
|
|
let request = interceptor.call(request).unwrap();
|
|
assert_eq!(
|
|
request.metadata().get("authorization").unwrap(),
|
|
"Bearer caller-value"
|
|
);
|
|
assert!(format!("{interceptor:?}").contains("enabled: false"));
|
|
}
|
|
}
|