|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +//! The in-pod binary-identity resolver (RFC 0012 runtime contract). |
| 5 | +//! |
| 6 | +//! RFC 0012 delivers executable identity on every |
| 7 | +//! [`MediatedConnection`](openshell_isolation::contract::MediatedConnection): |
| 8 | +//! the backend resolves identity for the accepted connection before mediation. |
| 9 | +//! An unresolved identity denies that connection. This is the in-pod |
| 10 | +//! resolution mechanism — procfs, keyed by the workload-side TCP peer port — |
| 11 | +//! kept in this crate on purpose: the proxy that consumes identity is here, and |
| 12 | +//! so are procfs and the binary identity cache. Stronger backends may use a |
| 13 | +//! different resolution mechanism without changing the contract. The result |
| 14 | +//! type lives in the lower `openshell-isolation` crate (network -> isolation -> |
| 15 | +//! core, acyclic). |
| 16 | +//! |
| 17 | +//! The legacy listener still resolves identity in the proxy hot path. The RFC |
| 18 | +//! 0012 co-located source invokes this resolver before returning each accepted |
| 19 | +//! connection, so mediation consumes the bound identity result. |
| 20 | +
|
| 21 | +use std::sync::Arc; |
| 22 | +use std::sync::atomic::AtomicU32; |
| 23 | + |
| 24 | +use openshell_isolation::contract::{BinaryIdentity, ResolveError, Sha256Digest}; |
| 25 | + |
| 26 | +/// In-pod binary-identity resolver: reads and hashes the executable resolved |
| 27 | +/// for an accepted connection from procfs. Resolution fails closed; it never |
| 28 | +/// fabricates identity fields. |
| 29 | +#[derive(Clone)] |
| 30 | +pub struct ProcfsIdentityResolver { |
| 31 | + /// The workload entrypoint PID, whose network namespace owns the peer |
| 32 | + /// sockets the proxy resolves. Published once the agent starts. |
| 33 | + pub entrypoint_pid: Arc<AtomicU32>, |
| 34 | +} |
| 35 | + |
| 36 | +impl ProcfsIdentityResolver { |
| 37 | + /// Resolve the executable identity behind an accepted workload connection. |
| 38 | + pub fn resolve_connection( |
| 39 | + &self, |
| 40 | + workload_addr: std::net::SocketAddr, |
| 41 | + proxy_addr: std::net::SocketAddr, |
| 42 | + ) -> Result<BinaryIdentity, ResolveError> { |
| 43 | + // procfs resolution is Linux-only; on other targets the supervisor has |
| 44 | + // no procfs to read, so resolution fails closed. |
| 45 | + #[cfg(target_os = "linux")] |
| 46 | + { |
| 47 | + self.resolve_via_procfs(workload_addr, proxy_addr) |
| 48 | + } |
| 49 | + #[cfg(not(target_os = "linux"))] |
| 50 | + { |
| 51 | + let _ = (workload_addr, proxy_addr); |
| 52 | + Err(ResolveError::Failed( |
| 53 | + "no procfs on this platform; identity resolution unavailable".to_string(), |
| 54 | + )) |
| 55 | + } |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +#[cfg(target_os = "linux")] |
| 60 | +impl ProcfsIdentityResolver { |
| 61 | + fn resolve_via_procfs( |
| 62 | + &self, |
| 63 | + workload_addr: std::net::SocketAddr, |
| 64 | + proxy_addr: std::net::SocketAddr, |
| 65 | + ) -> Result<BinaryIdentity, ResolveError> { |
| 66 | + use std::sync::atomic::Ordering; |
| 67 | + |
| 68 | + let entrypoint_pid = self.entrypoint_pid.load(Ordering::Acquire); |
| 69 | + if entrypoint_pid == 0 { |
| 70 | + // No workload yet: nothing to attribute the connection to. Fail |
| 71 | + // closed so a binary-scoped rule cannot match an unattributed peer. |
| 72 | + return Err(ResolveError::NotFound); |
| 73 | + } |
| 74 | + |
| 75 | + let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); |
| 76 | + let owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, connection) |
| 77 | + .map_err(|_| ResolveError::NotFound)?; |
| 78 | + let mut identities = Vec::with_capacity(owners.owners.len()); |
| 79 | + for owner in owners.owners { |
| 80 | + identities.push(Self::resolve_owner(owner.pid, entrypoint_pid)?); |
| 81 | + } |
| 82 | + let Some(identity) = identities.first().cloned() else { |
| 83 | + return Err(ResolveError::NotFound); |
| 84 | + }; |
| 85 | + if identities.iter().skip(1).any(|candidate| { |
| 86 | + candidate.binary_path != identity.binary_path |
| 87 | + || candidate.binary_digest != identity.binary_digest |
| 88 | + || candidate.ancestors != identity.ancestors |
| 89 | + || candidate.cmdline_paths != identity.cmdline_paths |
| 90 | + }) { |
| 91 | + return Err(ResolveError::Failed( |
| 92 | + "shared socket owners have different policy identities".to_string(), |
| 93 | + )); |
| 94 | + } |
| 95 | + Ok(identity) |
| 96 | + } |
| 97 | + |
| 98 | + fn resolve_owner(owner_pid: u32, entrypoint_pid: u32) -> Result<BinaryIdentity, ResolveError> { |
| 99 | + let binary_path = crate::procfs::binary_path(owner_pid.cast_signed()) |
| 100 | + .map_err(|error| ResolveError::Failed(error.to_string()))?; |
| 101 | + |
| 102 | + // Hash the live `/proc/<pid>/exe` object, not the reopened resolved |
| 103 | + // path: opening the magic symlink pins the inode the process is actually |
| 104 | + // executing, so a post-resolution swap of the path cannot launder the |
| 105 | + // hash. A missing digest is `None`, never an empty string, and an |
| 106 | + // unhashable binary fails closed rather than asserting an identity the |
| 107 | + // resolver could not verify. |
| 108 | + let exe = std::path::PathBuf::from(format!("/proc/{owner_pid}/exe")); |
| 109 | + let binary_digest = match crate::procfs::file_sha256(&exe) { |
| 110 | + Ok(digest) => Some(digest.parse::<Sha256Digest>()?), |
| 111 | + Err(_) => { |
| 112 | + return Err(ResolveError::Failed( |
| 113 | + "could not hash resolved executable; refusing to assert identity".to_string(), |
| 114 | + )); |
| 115 | + } |
| 116 | + }; |
| 117 | + |
| 118 | + let ancestors = crate::procfs::collect_ancestor_binaries(owner_pid, entrypoint_pid); |
| 119 | + let mut exclude = ancestors.clone(); |
| 120 | + exclude.push(binary_path.clone()); |
| 121 | + let cmdline_paths = |
| 122 | + crate::procfs::collect_cmdline_paths(owner_pid, entrypoint_pid, &exclude); |
| 123 | + |
| 124 | + Ok(BinaryIdentity { |
| 125 | + binary_path, |
| 126 | + binary_digest, |
| 127 | + ancestors, |
| 128 | + cmdline_paths, |
| 129 | + }) |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +#[cfg(test)] |
| 134 | +mod tests { |
| 135 | + use super::*; |
| 136 | + |
| 137 | + /// Stands in for the mediation service: a binary-scoped rule can only be |
| 138 | + /// authorized by a resolved identity carrying the fields it requires. |
| 139 | + fn admits_binary_rule(result: Result<BinaryIdentity, ResolveError>) -> bool { |
| 140 | + matches!(result, Ok(identity) if identity.binary_digest.is_some()) |
| 141 | + } |
| 142 | + |
| 143 | + #[test] |
| 144 | + fn fails_closed_before_the_workload_starts() { |
| 145 | + // entrypoint_pid == 0 means no agent yet; identity must fail closed so a |
| 146 | + // binary-scoped rule cannot be satisfied by an unattributed connection. |
| 147 | + let resolver = ProcfsIdentityResolver { |
| 148 | + entrypoint_pid: Arc::new(AtomicU32::new(0)), |
| 149 | + }; |
| 150 | + assert!(!admits_binary_rule(resolver.resolve_connection( |
| 151 | + "127.0.0.1:12345".parse().unwrap(), |
| 152 | + "127.0.0.1:3128".parse().unwrap(), |
| 153 | + ))); |
| 154 | + } |
| 155 | + |
| 156 | + #[test] |
| 157 | + fn unknown_peer_fails_closed() { |
| 158 | + // A peer port no live workload connection owns must resolve to an error, |
| 159 | + // never a fabricated identity. |
| 160 | + let resolver = ProcfsIdentityResolver { |
| 161 | + entrypoint_pid: Arc::new(AtomicU32::new(u32::MAX - 1)), |
| 162 | + }; |
| 163 | + assert!(!admits_binary_rule(resolver.resolve_connection( |
| 164 | + "127.0.0.1:1".parse().unwrap(), |
| 165 | + "127.0.0.1:3128".parse().unwrap(), |
| 166 | + ))); |
| 167 | + } |
| 168 | +} |
0 commit comments