Skip to main content

basil_core/core/
peer.rs

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