Skip to main content

basil_core/core/
peer.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Peer attestation for accepted connections.
6//!
7//! Adapted from `brightnexus-platform`'s `attestation.rs` and the `PeerInfo`
8//! shape in `brightnexus-core`'s `handler.rs`. We derive the connecting peer's
9//! `(uid, gid, pid)` from `SO_PEERCRED` (via Tokio's `UCred`) and enrich it from
10//! `/proc`: the executable path, the process lineage, and any wrapping SSH
11//! session.
12//!
13//! Detail note: Linux `SO_PEERCRED` is a snapshot of the peer's creds captured at time
14//! of `connect(2)`. For short-lived RPC connections, or when called from a systemd
15//! service, creds at connect time is almost always the same as current effective creds.
16//!
17//! On `MacOS` (not officially supported, but it works), `getpeerid/LOCAL_PEERPID` returns
18//! the peer's current effective creds.
19//!
20
21use std::collections::HashMap;
22use std::fs;
23use std::io::Read;
24
25use tokio::net::UnixStream;
26
27const LINEAGE_CAP: usize = 8;
28
29/// Identity and attestation facts about a connected peer.
30#[derive(Debug, Clone)]
31pub struct PeerInfo {
32    pub pid: Option<u32>,
33    pub uid: Option<u32>,
34    pub gid: Option<u32>,
35    pub executable_path: Option<String>,
36    pub attestation_class: String,
37    pub subject_id: Option<String>,
38    pub signature_valid: bool,
39    pub display_label: Option<String>,
40}
41
42impl Default for PeerInfo {
43    fn default() -> Self {
44        Self {
45            pid: None,
46            uid: None,
47            gid: None,
48            executable_path: None,
49            // No credential resolved yet; enrichment may upgrade this.
50            attestation_class: "Unknown".into(),
51            subject_id: None,
52            // No package signature is ever checked on NixOS (no dpkg).
53            signature_valid: false,
54            display_label: None,
55        }
56    }
57}
58
59impl PeerInfo {
60    /// Attest the peer on the far end of `stream`.
61    #[must_use]
62    pub fn from_stream(stream: &UnixStream) -> Self {
63        stream.peer_cred().map_or_else(
64            |_| Self::default(),
65            |cred| {
66                // `mut` is consumed only by the Linux-only `enrich_linux` below;
67                // on other targets (e.g. macOS) the binding is never mutated.
68                #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
69                let mut info = Self {
70                    pid: cred.pid().map(i32::cast_unsigned),
71                    uid: Some(cred.uid()),
72                    gid: Some(cred.gid()),
73                    ..Default::default()
74                };
75                #[cfg(target_os = "linux")]
76                enrich_linux(&mut info);
77                info
78            },
79        )
80    }
81
82    /// Build peer information from already-captured Unix credentials.
83    ///
84    /// tonic captures connection info before dispatching requests, so gRPC
85    /// handlers receive the credential facts through request extensions rather
86    /// than from the original [`UnixStream`].
87    #[must_use]
88    pub fn from_unix_cred(pid: Option<u32>, uid: u32, gid: u32) -> Self {
89        // `mut` is consumed only by the Linux-only `enrich_linux` below; on
90        // other targets (e.g. macOS) the binding is never mutated.
91        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
92        let mut info = Self {
93            pid,
94            uid: Some(uid),
95            gid: Some(gid),
96            ..Default::default()
97        };
98        #[cfg(target_os = "linux")]
99        enrich_linux(&mut info);
100        info
101    }
102}
103
104/// Fill in the executable path from `/proc`.
105///
106/// This broker targets NixOS, which has no `dpkg` package database, so there is
107/// no Debian package verification: the attestation is the peer's credential plus
108/// its `/proc` executable path. `signature_valid` is always `false` here (there
109/// is no package signature to check); a `/proc` exe we can resolve sets
110/// `attestation_class = "PeerCred"`, otherwise it stays the default `"Unknown"`.
111#[cfg(target_os = "linux")]
112fn enrich_linux(info: &mut PeerInfo) {
113    if let Some(pid) = info.pid {
114        let exe = fs::read_link(format!("/proc/{pid}/exe"))
115            .ok()
116            .map(|p| p.to_string_lossy().into_owned());
117        info.executable_path.clone_from(&exe);
118        info.display_label.clone_from(&exe);
119        if exe.is_some() {
120            info.attestation_class = "PeerCred".into();
121        }
122    }
123}
124
125/// Read a process's environment from `/proc/<pid>/environ`.
126#[must_use]
127pub fn read_proc_environ(pid: u32) -> HashMap<String, String> {
128    let mut map = HashMap::new();
129    if let Ok(mut f) = fs::File::open(format!("/proc/{pid}/environ")) {
130        let mut buf = Vec::new();
131        if f.read_to_end(&mut buf).is_ok() {
132            for part in buf.split(|&b| b == 0) {
133                if let Ok(s) = std::str::from_utf8(part)
134                    && let Some((k, v)) = s.split_once('=')
135                {
136                    map.insert(k.to_string(), v.to_string());
137                }
138            }
139        }
140    }
141    map
142}
143
144/// Walk the parent-PID chain starting at `pid` (capped at [`LINEAGE_CAP`]).
145#[must_use]
146pub fn lineage_pids(pid: u32) -> Vec<u32> {
147    let mut out = vec![pid];
148    let mut current = pid;
149    for _ in 0..LINEAGE_CAP {
150        let Ok(status) = fs::read_to_string(format!("/proc/{current}/status")) else {
151            break;
152        };
153        let parent = status
154            .lines()
155            .find(|l| l.starts_with("PPid:"))
156            .and_then(|l| l.split_whitespace().nth(1))
157            .and_then(|s| s.parse().ok())
158            .unwrap_or(0);
159        if parent == 0 || out.contains(&parent) {
160            break;
161        }
162        out.push(parent);
163        current = parent;
164    }
165    out
166}
167
168/// If any ancestor in `lineage` is an `sshd`, return the
169/// `(user, remote_host, sshd_pid)` session it established.
170///
171/// Advisory only: on NixOS there is no `dpkg` package database, so the `sshd`
172/// process is identified by its `/proc` executable path (not a verified Debian
173/// package). A `/proc/<pid>/exe` we cannot read is skipped, not fatal.
174#[must_use]
175pub fn detect_ssh_session(lineage: &[u32]) -> Option<(String, String, u32)> {
176    for &pid in lineage {
177        let Ok(exe) = fs::read_link(format!("/proc/{pid}/exe")) else {
178            continue;
179        };
180        if !exe.to_string_lossy().contains("sshd") {
181            continue;
182        }
183        let env = read_proc_environ(pid);
184        let host = remote_host_from_ssh_connection(env.get("SSH_CONNECTION").map(String::as_str));
185        let user = env.get("USER").cloned().unwrap_or_default();
186        return Some((user, host, pid));
187    }
188    None
189}
190
191/// Extract the remote (client) host from an `SSH_CONNECTION` value.
192///
193/// `SSH_CONNECTION` is `"<client_ip> <client_port> <server_ip> <server_port>"`;
194/// the remote host the broker cares about is the **server-side** address the
195/// session terminated on (field index 2). A malformed/absent value yields `""`.
196fn remote_host_from_ssh_connection(conn: Option<&str>) -> String {
197    conn.unwrap_or_default()
198        .split_whitespace()
199        .nth(2)
200        .unwrap_or_default()
201        .to_string()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn ssh_connection_host_is_the_server_side_address() {
210        // "<client_ip> <client_port> <server_ip> <server_port>".
211        assert_eq!(
212            remote_host_from_ssh_connection(Some("10.0.0.5 51000 10.0.0.1 22")),
213            "10.0.0.1"
214        );
215    }
216
217    #[test]
218    fn ssh_connection_missing_or_malformed_is_empty() {
219        assert_eq!(remote_host_from_ssh_connection(None), "");
220        assert_eq!(remote_host_from_ssh_connection(Some("")), "");
221        assert_eq!(remote_host_from_ssh_connection(Some("only two")), "");
222    }
223
224    #[test]
225    fn default_peer_is_unknown_and_unsigned() {
226        let p = PeerInfo::default();
227        assert_eq!(p.attestation_class, "Unknown");
228        assert!(!p.signature_valid);
229        assert!(p.subject_id.is_none());
230    }
231}