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