Skip to main content

kunobi_daemon/
peer.rs

1//! Authenticate the process at the other end of a local connection.
2//!
3//! The flow has three steps. [`Credentials`] hold what the operating system
4//! reports for one connection; with the `local` feature, any connection that
5//! implements `local::peer::PeerCredentials` collects them. A [`Policy`] turns
6//! credentials into a grant or a [`Rejected`] reason. [`authenticate`] runs the
7//! policy and returns an [`Authenticated`] connection that carries its grant
8//! into dispatch, so a privileged handler can require that grant in its
9//! signature.
10//!
11//! The built-in policies compose: [`SameUser`] accepts any process of this OS
12//! user, [`ExpectedProcess`] also requires the PID the caller expects, and
13//! [`First`] maps the first policy that accepts to a grant of the consumer's
14//! own type. Consumers implement [`Policy`] for anything else.
15//!
16//! A PID match is numeric agreement between the PID the kernel reports for the
17//! connection and the PID the caller expected, usually read from a record its
18//! peer published. It does not prove who wrote that record, which executable
19//! the peer runs, or which process writes later bytes: a process of the same
20//! OS user can publish its own PID, and a descriptor can be passed or inherited.
21//! The consumer decides what a grant allows.
22
23use crate::ProcessId;
24use std::{borrow::Cow, fmt};
25
26/// What the operating system reports about the other end of one connection.
27///
28/// Read them while the connection is open and before dispatching anything.
29/// Later fields, such as a code-signature result, will be added without a
30/// breaking change; construct them with [`Credentials::new`].
31///
32/// Equality compares `pid` and `same_user` only. `user_evidence` says where
33/// `same_user` came from; compare it explicitly when it matters.
34#[derive(Clone, Debug)]
35#[non_exhaustive]
36pub struct Credentials {
37    /// Kernel-reported PID of the peer, or `None` when the platform reports none.
38    pub pid: Option<ProcessId>,
39    /// Whether the peer runs as this process's OS user.
40    pub same_user: bool,
41    /// Where `same_user` came from.
42    pub user_evidence: UserEvidence,
43}
44
45impl PartialEq for Credentials {
46    fn eq(&self, other: &Self) -> bool {
47        self.pid == other.pid && self.same_user == other.same_user
48    }
49}
50
51impl Eq for Credentials {}
52
53impl Credentials {
54    /// Credentials from an OS query, or for a consumer's own tests and sources.
55    /// The user check is taken to come from the connection; see
56    /// [`Self::with_user_evidence`].
57    pub const fn new(pid: Option<ProcessId>, same_user: bool) -> Self {
58        Self {
59            pid,
60            same_user,
61            user_evidence: UserEvidence::Connection,
62        }
63    }
64
65    /// The same credentials, with `same_user` coming from `evidence`.
66    pub const fn with_user_evidence(self, evidence: UserEvidence) -> Self {
67        Self {
68            user_evidence: evidence,
69            ..self
70        }
71    }
72}
73
74/// Where [`Credentials::same_user`] came from.
75///
76/// A policy that must not rest on a PID can require [`Self::Connection`]; the
77/// built-in policies accept either.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum UserEvidence {
81    /// The user the OS recorded for the connection itself: the Unix kernel's
82    /// peer credentials, or a named-pipe client's own security context.
83    Connection,
84    /// The user of the process that has the connection's peer PID. If the peer
85    /// has exited while another process holds its end, a process that reused
86    /// the PID is checked instead. A Windows client checking its server gets
87    /// this, and so does a server that has not yet read from a client under
88    /// dynamic context tracking.
89    PidLookup,
90}
91
92/// Why a policy refused a connection.
93#[derive(Clone, Debug, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum Rejected {
96    /// The peer runs as another OS user.
97    OtherUser,
98    /// The platform reported no PID for the connection. Missing credentials
99    /// are never a match.
100    Unreported,
101    /// The policy had no expected process to compare with, for example because
102    /// the record naming it is missing.
103    NoExpectedProcess,
104    /// The connection belongs to another process.
105    OtherProcess {
106        /// The PID the policy expected.
107        expected: ProcessId,
108        /// The PID the operating system reported for the connection.
109        observed: ProcessId,
110    },
111    /// The policy has no rules, so it accepts nobody.
112    NoPolicy,
113    /// A consumer's own policy refused the peer, for the reason given.
114    Custom(Cow<'static, str>),
115}
116
117impl fmt::Display for Rejected {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            Self::OtherUser => f.write_str("the peer runs as another OS user"),
121            Self::Unreported => f.write_str("the connection's peer PID is not available"),
122            Self::NoExpectedProcess => f.write_str("no expected process to compare with"),
123            Self::OtherProcess { expected, observed } => {
124                write!(f, "peer PID {observed} is not the expected PID {expected}")
125            }
126            Self::NoPolicy => f.write_str("no policy accepts any peer"),
127            Self::Custom(reason) => f.write_str(reason),
128        }
129    }
130}
131
132impl std::error::Error for Rejected {}
133
134/// The connection's PID equals the expected PID. See the module docs for what
135/// this does not establish.
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub struct PidMatch {
138    pid: ProcessId,
139}
140
141impl PidMatch {
142    /// The PID both sides agreed on.
143    pub const fn pid(&self) -> ProcessId {
144        self.pid
145    }
146}
147
148/// Compare the PID reported for a connection with the expected process. Only
149/// [`ExpectedProcess`] calls this, after its same-user check.
150fn match_pid(observed: Option<ProcessId>, expected: ProcessId) -> Result<PidMatch, Rejected> {
151    match observed {
152        None => Err(Rejected::Unreported),
153        Some(observed) if observed == expected => Ok(PidMatch { pid: observed }),
154        Some(observed) => Err(Rejected::OtherProcess { expected, observed }),
155    }
156}
157
158/// Decide what a connection may do from its [`Credentials`].
159pub trait Policy {
160    /// What an accepted connection is allowed, in the consumer's own terms.
161    type Grant;
162    /// Grant access or say why not. Runs on the accepting task, so it must not
163    /// block or perform I/O: read state the consumer keeps current elsewhere.
164    fn grant(&self, credentials: &Credentials) -> Result<Self::Grant, Rejected>;
165}
166
167impl<P: Policy + ?Sized> Policy for &P {
168    type Grant = P::Grant;
169    fn grant(&self, credentials: &Credentials) -> Result<P::Grant, Rejected> {
170        (**self).grant(credentials)
171    }
172}
173
174impl<P: Policy + ?Sized> Policy for Box<P> {
175    type Grant = P::Grant;
176    fn grant(&self, credentials: &Credentials) -> Result<P::Grant, Rejected> {
177        (**self).grant(credentials)
178    }
179}
180
181/// Accept any process of this OS user.
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
183pub struct SameUser;
184
185impl Policy for SameUser {
186    type Grant = ();
187    fn grant(&self, credentials: &Credentials) -> Result<(), Rejected> {
188        if credentials.same_user {
189            Ok(())
190        } else {
191            Err(Rejected::OtherUser)
192        }
193    }
194}
195
196/// Accept one process of this OS user, whose PID `expected` returns when asked.
197///
198/// `expected` is called at most once per [`Policy::grant`], and only after the
199/// same-user check passes. Like the policy, it must not block or perform I/O:
200/// return a PID the consumer already read, for example from a record it
201/// watches, or `None` when there is no process to accept.
202#[derive(Clone, Copy)]
203pub struct ExpectedProcess<F> {
204    expected: F,
205}
206
207impl<F: Fn() -> Option<ProcessId>> ExpectedProcess<F> {
208    /// Accept the process `expected` names at connection time.
209    pub const fn new(expected: F) -> Self {
210        Self { expected }
211    }
212}
213
214impl<F: Fn() -> Option<ProcessId>> Policy for ExpectedProcess<F> {
215    type Grant = PidMatch;
216    fn grant(&self, credentials: &Credentials) -> Result<PidMatch, Rejected> {
217        SameUser.grant(credentials)?;
218        let expected = (self.expected)().ok_or(Rejected::NoExpectedProcess)?;
219        match_pid(credentials.pid, expected)
220    }
221}
222
223type Tier<G> = Box<dyn Fn(&Credentials) -> Result<G, Rejected> + Send + Sync>;
224
225/// Try policies in order and grant the value paired with the first one that
226/// accepts. When none accepts, the last policy's reason is returned, so put the
227/// broadest policy last.
228///
229/// ```
230/// use kunobi_daemon::{
231///     ProcessId,
232///     peer::{Credentials, ExpectedProcess, First, Policy, SameUser},
233/// };
234///
235/// #[derive(Clone, Debug, PartialEq)]
236/// enum View { Full, Redacted }
237///
238/// let app = ProcessId::new(42);
239/// let policy = First::new()
240///     .then(ExpectedProcess::new(move || app), View::Full)
241///     .then(SameUser, View::Redacted);
242///
243/// assert_eq!(policy.grant(&Credentials::new(app, true)), Ok(View::Full));
244/// assert_eq!(policy.grant(&Credentials::new(ProcessId::new(7), true)), Ok(View::Redacted));
245/// assert!(policy.grant(&Credentials::new(app, false)).is_err());
246/// ```
247pub struct First<G> {
248    tiers: Vec<Tier<G>>,
249}
250
251impl<G> Default for First<G> {
252    fn default() -> Self {
253        Self { tiers: Vec::new() }
254    }
255}
256
257impl<G> fmt::Debug for First<G> {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_struct("First")
260            .field("tiers", &self.tiers.len())
261            .finish()
262    }
263}
264
265impl<G: Clone + Send + Sync + 'static> First<G> {
266    /// An empty list, which rejects every connection with [`Rejected::NoPolicy`].
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Grant `grant` when `policy` accepts and no earlier policy did.
272    pub fn then<P>(mut self, policy: P, grant: G) -> Self
273    where
274        P: Policy + Send + Sync + 'static,
275    {
276        self.tiers.push(Box::new(move |credentials| {
277            policy.grant(credentials).map(|_| grant.clone())
278        }));
279        self
280    }
281}
282
283impl<G> Policy for First<G> {
284    type Grant = G;
285    fn grant(&self, credentials: &Credentials) -> Result<G, Rejected> {
286        let mut rejected = Rejected::NoPolicy;
287        for tier in &self.tiers {
288            match tier(credentials) {
289                Ok(grant) => return Ok(grant),
290                Err(reason) => rejected = reason,
291            }
292        }
293        Err(rejected)
294    }
295}
296
297/// A connection whose peer a [`Policy`] accepted, with the grant it received.
298///
299/// Reads and writes pass through to the connection. There is no mutable access
300/// to the connection itself, so it cannot be swapped for another one while the
301/// grant is kept; take it out with [`Self::into_parts`] and authenticate again.
302#[derive(Debug)]
303pub struct Authenticated<C, G> {
304    connection: C,
305    grant: G,
306    credentials: Credentials,
307}
308
309impl<C, G> Authenticated<C, G> {
310    /// What the policy granted this connection.
311    pub fn grant(&self) -> &G {
312        &self.grant
313    }
314
315    /// The credentials the policy accepted.
316    pub fn credentials(&self) -> &Credentials {
317        &self.credentials
318    }
319
320    /// The connection, for serving it.
321    pub fn connection(&self) -> &C {
322        &self.connection
323    }
324
325    /// Give up the wrapper.
326    pub fn into_parts(self) -> (C, G, Credentials) {
327        (self.connection, self.grant, self.credentials)
328    }
329}
330
331impl<C: std::io::Read, G> std::io::Read for Authenticated<C, G> {
332    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
333        self.connection.read(buf)
334    }
335}
336
337impl<C: std::io::Write, G> std::io::Write for Authenticated<C, G> {
338    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
339        self.connection.write(buf)
340    }
341    fn flush(&mut self) -> std::io::Result<()> {
342        self.connection.flush()
343    }
344}
345
346#[cfg(feature = "async")]
347impl<C: tokio::io::AsyncRead + Unpin, G: Unpin> tokio::io::AsyncRead for Authenticated<C, G> {
348    fn poll_read(
349        self: std::pin::Pin<&mut Self>,
350        cx: &mut std::task::Context<'_>,
351        buf: &mut tokio::io::ReadBuf<'_>,
352    ) -> std::task::Poll<std::io::Result<()>> {
353        std::pin::Pin::new(&mut self.get_mut().connection).poll_read(cx, buf)
354    }
355}
356
357#[cfg(feature = "async")]
358impl<C: tokio::io::AsyncWrite + Unpin, G: Unpin> tokio::io::AsyncWrite for Authenticated<C, G> {
359    fn poll_write(
360        self: std::pin::Pin<&mut Self>,
361        cx: &mut std::task::Context<'_>,
362        buf: &[u8],
363    ) -> std::task::Poll<std::io::Result<usize>> {
364        std::pin::Pin::new(&mut self.get_mut().connection).poll_write(cx, buf)
365    }
366    fn poll_flush(
367        self: std::pin::Pin<&mut Self>,
368        cx: &mut std::task::Context<'_>,
369    ) -> std::task::Poll<std::io::Result<()>> {
370        std::pin::Pin::new(&mut self.get_mut().connection).poll_flush(cx)
371    }
372    fn poll_shutdown(
373        self: std::pin::Pin<&mut Self>,
374        cx: &mut std::task::Context<'_>,
375    ) -> std::task::Poll<std::io::Result<()>> {
376        std::pin::Pin::new(&mut self.get_mut().connection).poll_shutdown(cx)
377    }
378}
379
380/// Run `policy` on `credentials` for `connection`. Collect the credentials from
381/// the same connection, while it is open, before calling this: `authenticate`
382/// cannot check where they came from.
383pub fn authenticate<C, P: Policy + ?Sized>(
384    connection: C,
385    credentials: Credentials,
386    policy: &P,
387) -> Result<Authenticated<C, P::Grant>, Rejected> {
388    let grant = policy.grant(&credentials)?;
389    Ok(Authenticated {
390        connection,
391        grant,
392        credentials,
393    })
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn pid(value: u32) -> ProcessId {
401        ProcessId::new(value).unwrap()
402    }
403
404    fn creds(pid_value: u32, same_user: bool) -> Credentials {
405        Credentials::new(Some(pid(pid_value)), same_user)
406    }
407
408    #[test]
409    fn credentials_say_where_the_user_check_came_from() {
410        let connection = creds(7, true);
411        assert_eq!(connection.user_evidence, UserEvidence::Connection);
412        let lookup = connection
413            .clone()
414            .with_user_evidence(UserEvidence::PidLookup);
415        assert_eq!(lookup.user_evidence, UserEvidence::PidLookup);
416        assert_eq!(
417            (lookup.pid, lookup.same_user),
418            (connection.pid, connection.same_user)
419        );
420        // Equality is about what was reported, not where it came from.
421        assert_eq!(lookup, connection);
422        assert_ne!(creds(7, false), connection);
423        assert_ne!(creds(8, true), connection);
424    }
425
426    #[test]
427    fn only_an_equal_reported_pid_matches() {
428        assert_eq!(match_pid(Some(pid(7)), pid(7)).map(|m| m.pid()), Ok(pid(7)));
429        assert_eq!(
430            match_pid(Some(pid(8)), pid(7)),
431            Err(Rejected::OtherProcess {
432                expected: pid(7),
433                observed: pid(8)
434            })
435        );
436        assert_eq!(match_pid(None, pid(7)), Err(Rejected::Unreported));
437    }
438
439    #[test]
440    fn same_user_rejects_another_user_whatever_its_pid() {
441        assert_eq!(SameUser.grant(&creds(7, true)), Ok(()));
442        assert_eq!(SameUser.grant(&Credentials::new(None, true)), Ok(()));
443        assert_eq!(SameUser.grant(&creds(7, false)), Err(Rejected::OtherUser));
444    }
445
446    #[test]
447    fn expected_process_needs_the_same_user_a_record_and_the_same_pid() {
448        let app = ExpectedProcess::new(|| ProcessId::new(42));
449        assert_eq!(app.grant(&creds(42, true)).map(|m| m.pid()), Ok(pid(42)));
450        assert_eq!(app.grant(&creds(42, false)), Err(Rejected::OtherUser));
451        assert_eq!(
452            app.grant(&creds(7, true)),
453            Err(Rejected::OtherProcess {
454                expected: pid(42),
455                observed: pid(7)
456            })
457        );
458        assert_eq!(
459            app.grant(&Credentials::new(None, true)),
460            Err(Rejected::Unreported)
461        );
462        let missing = ExpectedProcess::new(|| None);
463        assert_eq!(
464            missing.grant(&creds(42, true)),
465            Err(Rejected::NoExpectedProcess)
466        );
467    }
468
469    #[derive(Clone, Debug, PartialEq)]
470    enum View {
471        Full,
472        Redacted,
473    }
474
475    fn tiered() -> First<View> {
476        First::new()
477            .then(ExpectedProcess::new(|| ProcessId::new(42)), View::Full)
478            .then(SameUser, View::Redacted)
479    }
480
481    #[test]
482    fn first_grants_the_first_accepting_tier_in_order() {
483        assert_eq!(tiered().grant(&creds(42, true)), Ok(View::Full));
484        assert_eq!(tiered().grant(&creds(7, true)), Ok(View::Redacted));
485        assert_eq!(tiered().grant(&creds(42, false)), Err(Rejected::OtherUser));
486        // Order decides: the broad tier first shadows the narrow one.
487        let shadowed = First::new()
488            .then(SameUser, View::Redacted)
489            .then(ExpectedProcess::new(|| ProcessId::new(42)), View::Full);
490        assert_eq!(shadowed.grant(&creds(42, true)), Ok(View::Redacted));
491    }
492
493    #[test]
494    fn an_empty_list_rejects_every_connection() {
495        assert_eq!(
496            First::<View>::new().grant(&creds(42, true)),
497            Err(Rejected::NoPolicy)
498        );
499    }
500
501    #[test]
502    fn references_and_boxes_of_a_policy_are_policies() {
503        fn by_value<P: Policy>(policy: P, credentials: &Credentials) -> Result<P::Grant, Rejected> {
504            policy.grant(credentials)
505        }
506        let shared = tiered();
507        assert_eq!(by_value(&shared, &creds(42, true)), Ok(View::Full));
508        assert_eq!(by_value(&shared, &creds(7, true)), Ok(View::Redacted));
509        let boxed: Box<dyn Policy<Grant = ()>> = Box::new(SameUser);
510        assert_eq!(boxed.grant(&creds(7, false)), Err(Rejected::OtherUser));
511        assert_eq!(boxed.grant(&creds(7, true)), Ok(()));
512    }
513
514    #[test]
515    fn authenticate_carries_the_grant_and_credentials_with_the_connection() {
516        let credentials = creds(42, true);
517        let accepted = authenticate("conn", credentials.clone(), &tiered()).unwrap();
518        assert_eq!(accepted.grant(), &View::Full);
519        assert_eq!(accepted.credentials(), &credentials);
520        assert_eq!(*accepted.connection(), "conn");
521        assert_eq!(
522            accepted.into_parts(),
523            ("conn", View::Full, credentials.clone())
524        );
525        let dynamic: &dyn Policy<Grant = ()> = &SameUser;
526        assert!(authenticate((), credentials, dynamic).is_ok());
527        assert_eq!(
528            authenticate("conn", creds(1, false), &tiered()).unwrap_err(),
529            Rejected::OtherUser
530        );
531    }
532
533    #[test]
534    fn reads_and_writes_pass_through_to_the_connection() {
535        use std::io::{Read, Write};
536        let mut reader = authenticate(&b"ping"[..], creds(1, true), &SameUser).unwrap();
537        let mut text = String::new();
538        reader.read_to_string(&mut text).unwrap();
539        assert_eq!(text, "ping");
540        // A buffered writer only reaches its inner vector on flush, so this
541        // fails if the passthrough does not flush the connection.
542        let mut writer = authenticate(
543            std::io::BufWriter::new(Vec::new()),
544            creds(1, true),
545            &SameUser,
546        )
547        .unwrap();
548        writer.write_all(b"pong").unwrap();
549        assert!(writer.connection().get_ref().is_empty());
550        writer.flush().unwrap();
551        assert_eq!(writer.connection().get_ref(), b"pong");
552    }
553
554    #[cfg(feature = "async")]
555    #[tokio::test]
556    async fn async_reads_and_writes_pass_through_to_the_connection() {
557        use tokio::io::{AsyncReadExt, AsyncWriteExt};
558        let mut reader = authenticate(&b"ping"[..], creds(1, true), &SameUser).unwrap();
559        let mut text = String::new();
560        reader.read_to_string(&mut text).await.unwrap();
561        assert_eq!(text, "ping");
562        // As above: the buffered writer's inner vector changes only on flush.
563        let mut writer = authenticate(
564            tokio::io::BufWriter::new(Vec::new()),
565            creds(1, true),
566            &SameUser,
567        )
568        .unwrap();
569        writer.write_all(b"pong").await.unwrap();
570        assert!(writer.connection().get_ref().is_empty());
571        writer.flush().await.unwrap();
572        assert_eq!(writer.connection().get_ref(), b"pong");
573        writer.shutdown().await.unwrap();
574    }
575
576    #[test]
577    fn first_reports_the_last_tiers_reason_when_none_accepts() {
578        struct Refuse;
579        impl Policy for Refuse {
580            type Grant = ();
581            fn grant(&self, _: &Credentials) -> Result<(), Rejected> {
582                Err(Rejected::Custom("closed for maintenance".into()))
583            }
584        }
585        let refuse_last = First::new()
586            .then(ExpectedProcess::new(|| None), View::Full)
587            .then(Refuse, View::Redacted);
588        assert_eq!(
589            refuse_last.grant(&creds(42, true)),
590            Err(Rejected::Custom("closed for maintenance".into()))
591        );
592        let missing_last = First::new()
593            .then(Refuse, View::Redacted)
594            .then(ExpectedProcess::new(|| None), View::Full);
595        assert_eq!(
596            missing_last.grant(&creds(42, true)),
597            Err(Rejected::NoExpectedProcess)
598        );
599    }
600
601    #[test]
602    fn the_expected_pid_is_read_once_and_only_when_needed() {
603        use std::sync::{
604            Arc,
605            atomic::{AtomicUsize, Ordering},
606        };
607        let calls = Arc::new(AtomicUsize::new(0));
608        let counted = {
609            let calls = Arc::clone(&calls);
610            move || {
611                calls.fetch_add(1, Ordering::SeqCst);
612                ProcessId::new(42)
613            }
614        };
615        let app = ExpectedProcess::new(counted.clone());
616        assert!(app.grant(&creds(42, true)).is_ok());
617        assert_eq!(calls.load(Ordering::SeqCst), 1);
618        // Another user is refused before the record is consulted.
619        assert!(app.grant(&creds(42, false)).is_err());
620        assert_eq!(calls.load(Ordering::SeqCst), 1);
621        // A tier that already accepted stops the list.
622        let stop_early = First::new()
623            .then(SameUser, View::Redacted)
624            .then(ExpectedProcess::new(counted), View::Full);
625        assert_eq!(stop_early.grant(&creds(42, true)), Ok(View::Redacted));
626        assert_eq!(calls.load(Ordering::SeqCst), 1);
627    }
628
629    #[test]
630    fn every_rejection_says_why() {
631        let cases = [
632            (Rejected::OtherUser, "the peer runs as another OS user"),
633            (
634                Rejected::Unreported,
635                "the connection's peer PID is not available",
636            ),
637            (
638                Rejected::NoExpectedProcess,
639                "no expected process to compare with",
640            ),
641            (
642                Rejected::OtherProcess {
643                    expected: pid(42),
644                    observed: pid(7),
645                },
646                "peer PID 7 is not the expected PID 42",
647            ),
648            (Rejected::NoPolicy, "no policy accepts any peer"),
649            (Rejected::Custom("closed".into()), "closed"),
650        ];
651        for (rejected, text) in cases {
652            assert_eq!(rejected.to_string(), text);
653        }
654    }
655}