Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/openshell-supervisor-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ rust-version.workspace = true

[dependencies]
openshell-core = { path = "../openshell-core", features = ["oauth"] }
openshell-isolation = { path = "../openshell-isolation" }
openshell-ocsf = { path = "../openshell-ocsf" }
openshell-policy = { path = "../openshell-policy" }
openshell-router = { path = "../openshell-router" }
openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" }

async-trait = "0.1"

apollo-parser = { workspace = true }
aws-sigv4 = { version = "1", features = ["sign-http", "http1"] }
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
Expand Down
168 changes: 168 additions & 0 deletions crates/openshell-supervisor-network/src/identity_source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! The in-pod binary-identity resolver (RFC 0012 runtime contract).
//!
//! RFC 0012 delivers executable identity on every
//! [`MediatedConnection`](openshell_isolation::contract::MediatedConnection):
//! the backend resolves identity for the accepted connection before mediation.
//! An unresolved identity denies that connection. This is the in-pod
//! resolution mechanism — procfs, keyed by the workload-side TCP peer port —
//! kept in this crate on purpose: the proxy that consumes identity is here, and
//! so are procfs and the binary identity cache. Stronger backends may use a
//! different resolution mechanism without changing the contract. The result
//! type lives in the lower `openshell-isolation` crate (network -> isolation ->
//! core, acyclic).
//!
//! The legacy listener still resolves identity in the proxy hot path. The RFC
//! 0012 co-located source invokes this resolver before returning each accepted
//! connection, so mediation consumes the bound identity result.

use std::sync::Arc;
use std::sync::atomic::AtomicU32;

use openshell_isolation::contract::{BinaryIdentity, ResolveError, Sha256Digest};

/// In-pod binary-identity resolver: reads and hashes the executable resolved
/// for an accepted connection from procfs. Resolution fails closed; it never
/// fabricates identity fields.
#[derive(Clone)]
pub struct ProcfsIdentityResolver {
/// The workload entrypoint PID, whose network namespace owns the peer
/// sockets the proxy resolves. Published once the agent starts.
pub entrypoint_pid: Arc<AtomicU32>,
}

impl ProcfsIdentityResolver {
/// Resolve the executable identity behind an accepted workload connection.
pub fn resolve_connection(
&self,
workload_addr: std::net::SocketAddr,
proxy_addr: std::net::SocketAddr,
) -> Result<BinaryIdentity, ResolveError> {
// procfs resolution is Linux-only; on other targets the supervisor has
// no procfs to read, so resolution fails closed.
#[cfg(target_os = "linux")]
{
self.resolve_via_procfs(workload_addr, proxy_addr)
}
#[cfg(not(target_os = "linux"))]
{
let _ = (workload_addr, proxy_addr);
Err(ResolveError::Failed(
"no procfs on this platform; identity resolution unavailable".to_string(),
))
}
}
}

#[cfg(target_os = "linux")]
impl ProcfsIdentityResolver {
fn resolve_via_procfs(
&self,
workload_addr: std::net::SocketAddr,
proxy_addr: std::net::SocketAddr,
) -> Result<BinaryIdentity, ResolveError> {
use std::sync::atomic::Ordering;

let entrypoint_pid = self.entrypoint_pid.load(Ordering::Acquire);
if entrypoint_pid == 0 {
// No workload yet: nothing to attribute the connection to. Fail
// closed so a binary-scoped rule cannot match an unattributed peer.
return Err(ResolveError::NotFound);
}

let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr);
let owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, connection)
.map_err(|_| ResolveError::NotFound)?;
let mut identities = Vec::with_capacity(owners.owners.len());
for owner in owners.owners {
identities.push(Self::resolve_owner(owner.pid, entrypoint_pid)?);
}
let Some(identity) = identities.first().cloned() else {
return Err(ResolveError::NotFound);
};
if identities.iter().skip(1).any(|candidate| {
candidate.binary_path != identity.binary_path
|| candidate.binary_digest != identity.binary_digest
|| candidate.ancestors != identity.ancestors
|| candidate.cmdline_paths != identity.cmdline_paths
}) {
return Err(ResolveError::Failed(
"shared socket owners have different policy identities".to_string(),
));
}
Ok(identity)
}

fn resolve_owner(owner_pid: u32, entrypoint_pid: u32) -> Result<BinaryIdentity, ResolveError> {
let binary_path = crate::procfs::binary_path(owner_pid.cast_signed())
.map_err(|error| ResolveError::Failed(error.to_string()))?;

// Hash the live `/proc/<pid>/exe` object, not the reopened resolved
// path: opening the magic symlink pins the inode the process is actually
// executing, so a post-resolution swap of the path cannot launder the
// hash. A missing digest is `None`, never an empty string, and an
// unhashable binary fails closed rather than asserting an identity the
// resolver could not verify.
let exe = std::path::PathBuf::from(format!("/proc/{owner_pid}/exe"));
let binary_digest = match crate::procfs::file_sha256(&exe) {
Ok(digest) => Some(digest.parse::<Sha256Digest>()?),
Err(_) => {
return Err(ResolveError::Failed(
"could not hash resolved executable; refusing to assert identity".to_string(),
));
}
};

let ancestors = crate::procfs::collect_ancestor_binaries(owner_pid, entrypoint_pid);
let mut exclude = ancestors.clone();
exclude.push(binary_path.clone());
let cmdline_paths =
crate::procfs::collect_cmdline_paths(owner_pid, entrypoint_pid, &exclude);

Ok(BinaryIdentity {
binary_path,
binary_digest,
ancestors,
cmdline_paths,
})
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Stands in for the mediation service: a binary-scoped rule can only be
/// authorized by a resolved identity carrying the fields it requires.
fn admits_binary_rule(result: Result<BinaryIdentity, ResolveError>) -> bool {
matches!(result, Ok(identity) if identity.binary_digest.is_some())
}

#[test]
fn fails_closed_before_the_workload_starts() {
// entrypoint_pid == 0 means no agent yet; identity must fail closed so a
// binary-scoped rule cannot be satisfied by an unattributed connection.
let resolver = ProcfsIdentityResolver {
entrypoint_pid: Arc::new(AtomicU32::new(0)),
};
assert!(!admits_binary_rule(resolver.resolve_connection(
"127.0.0.1:12345".parse().unwrap(),
"127.0.0.1:3128".parse().unwrap(),
)));
}

#[test]
fn unknown_peer_fails_closed() {
// A peer port no live workload connection owns must resolve to an error,
// never a fabricated identity.
let resolver = ProcfsIdentityResolver {
entrypoint_pid: Arc::new(AtomicU32::new(u32::MAX - 1)),
};
assert!(!admits_binary_rule(resolver.resolve_connection(
"127.0.0.1:1".parse().unwrap(),
"127.0.0.1:3128".parse().unwrap(),
)));
}
}
10 changes: 6 additions & 4 deletions crates/openshell-supervisor-network/src/l7/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio_rustls::{TlsAcceptor, TlsConnector};

const MAX_CACHED_CERTS: usize = 256;
Expand Down Expand Up @@ -170,11 +169,14 @@ impl ProxyTlsState {
/// Accept TLS from a sandbox client, presenting a dynamic cert for the hostname.
///
/// Returns a TLS stream that can be used for plaintext HTTP inspection.
pub async fn tls_terminate_client(
client: TcpStream,
pub async fn tls_terminate_client<S>(
client: S,
tls_state: &ProxyTlsState,
hostname: &str,
) -> Result<impl AsyncRead + AsyncWrite + Unpin + Send> {
) -> Result<impl AsyncRead + AsyncWrite + Unpin + Send>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
let acceptor = tls_state.acceptor_for(hostname)?;
let tls_stream = acceptor.accept(client).await.into_diagnostic()?;
Ok(tls_stream)
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-network/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! aggregate them.

pub mod identity;
pub mod identity_source;
pub mod inference_routes;
pub mod l7;
pub mod opa;
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-supervisor-process/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ rust-version.workspace = true

[dependencies]
openshell-core = { path = "../openshell-core" }
openshell-isolation = { path = "../openshell-isolation" }
openshell-ocsf = { path = "../openshell-ocsf" }
openshell-policy = { path = "../openshell-policy" }

anyhow = { workspace = true }
async-trait = "0.1"
base64 = { workspace = true }
bytes = { workspace = true }
hex = "0.4"
Expand Down
Loading
Loading