Skip to main content

machine_krb/
identity.rs

1use std::fs::File;
2use std::path::PathBuf;
3
4use crate::error::{Error, Result};
5use crate::exec::{self, Tools};
6
7/// The machine's AD identity, discovered from its keytab.
8///
9/// The keytab is the ground truth for what this device can authenticate as —
10/// more reliable than deriving `HOSTNAME$` from the hostname, which drifts
11/// (renames, >15-char truncation, case).
12#[derive(Debug, Clone)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize))]
14pub struct MachineIdentity {
15    /// e.g. `WORKSTATION1$@EXAMPLE.COM`
16    pub principal: String,
17    /// e.g. `EXAMPLE.COM`
18    pub realm: String,
19    pub keytab: PathBuf,
20}
21
22impl MachineIdentity {
23    /// Read the keytab and pick the first machine (`NAME$@REALM`) principal.
24    ///
25    /// Needs read access to the keytab — i.e. root for `/etc/krb5.keytab`.
26    /// On a host joined to multiple realms, use [`Self::discover_in_realm`] to
27    /// choose which one.
28    pub fn discover(tools: &Tools, keytab: impl Into<PathBuf>) -> Result<Self> {
29        Self::pick(tools, keytab, None)
30    }
31
32    /// Like [`Self::discover`], but pick the machine principal whose realm
33    /// matches `realm` (case-insensitive) — for hosts joined to more than one
34    /// AD realm. Errors with [`Error::NoMachinePrincipalForRealm`] if none match.
35    pub fn discover_in_realm(
36        tools: &Tools,
37        keytab: impl Into<PathBuf>,
38        realm: &str,
39    ) -> Result<Self> {
40        Self::pick(tools, keytab, Some(realm))
41    }
42
43    fn pick(tools: &Tools, keytab: impl Into<PathBuf>, want_realm: Option<&str>) -> Result<Self> {
44        let keytab = keytab.into();
45        // Pre-check readability for a friendlier error than klist's.
46        if let Err(source) = File::open(&keytab) {
47            return Err(if source.kind() == std::io::ErrorKind::NotFound {
48                Error::KeytabMissing { keytab }
49            } else {
50                Error::KeytabUnreadable { keytab, source }
51            });
52        }
53        // "--" so a keytab path starting with '-' can't be misparsed as a flag.
54        let listing = exec::run_ok(
55            &tools.klist,
56            ["-k".as_ref(), "--".as_ref(), keytab.as_os_str()],
57        )?;
58        let principals = parse_machine_principals(&listing);
59        let principal = select_principal(&principals, want_realm)
60            .cloned()
61            .ok_or_else(|| match want_realm {
62                Some(realm) => Error::NoMachinePrincipalForRealm {
63                    realm: realm.to_string(),
64                    keytab: keytab.clone(),
65                },
66                None => Error::NoMachinePrincipal {
67                    keytab: keytab.clone(),
68                },
69            })?;
70        let realm = principal
71            .split_once('@')
72            .map(|(_, r)| r.to_string())
73            .unwrap_or_default();
74        Ok(Self {
75            principal,
76            realm,
77            keytab,
78        })
79    }
80}
81
82/// Choose a machine principal: the one whose realm matches `want_realm`
83/// (case-insensitive), or the first one when `want_realm` is `None`.
84pub(crate) fn select_principal<'a>(
85    principals: &'a [String],
86    want_realm: Option<&str>,
87) -> Option<&'a String> {
88    match want_realm {
89        Some(realm) => principals.iter().find(|p| {
90            p.rsplit_once('@')
91                .is_some_and(|(_, r)| r.eq_ignore_ascii_case(realm))
92        }),
93        None => principals.first(),
94    }
95}
96
97/// Extract machine (`NAME$@REALM`) principals from `klist -k` output,
98/// first-seen order, deduplicated (keytabs repeat principals per enctype/kvno).
99///
100/// Service principals (`host/…`, `RestrictedKrbHost/…`) are skipped — only the
101/// sAMAccountName form can `kinit -k` into a TGT usable as FAST armor.
102pub(crate) fn parse_machine_principals(klist_k: &str) -> Vec<String> {
103    let mut found: Vec<String> = Vec::new();
104    for line in klist_k.lines() {
105        let mut fields = line.split_whitespace();
106        let (Some(kvno), Some(principal)) = (fields.next(), fields.next()) else {
107            continue;
108        };
109        // Data rows start with a numeric KVNO; headers/rules don't.
110        if kvno.is_empty() || !kvno.bytes().all(|b| b.is_ascii_digit()) {
111            continue;
112        }
113        let Some((name, realm)) = principal.split_once('@') else {
114            continue;
115        };
116        // Reject a leading '-' outright: such a "principal" would later sit in
117        // argv positions where a tool could misparse it as a flag. No real AD
118        // sAMAccountName starts with '-'.
119        if name.ends_with('$')
120            && !name.starts_with('-')
121            && !name.contains('/')
122            && !realm.is_empty()
123            && !found.iter().any(|p| p == principal)
124        {
125            found.push(principal.to_string());
126        }
127    }
128    found
129}
130
131#[cfg(test)]
132mod tests {
133    use super::parse_machine_principals;
134
135    const KLIST_K: &str = "\
136Keytab name: FILE:/etc/krb5.keytab
137KVNO Principal
138---- --------------------------------------------------------------------------
139   2 WORKSTATION1$@EXAMPLE.COM
140   2 WORKSTATION1$@EXAMPLE.COM
141   2 host/WORKSTATION1@EXAMPLE.COM
142   2 host/workstation1.example.com@EXAMPLE.COM
143   2 RestrictedKrbHost/WORKSTATION1@EXAMPLE.COM
144";
145
146    #[test]
147    fn picks_machine_principal_only_once() {
148        let got = parse_machine_principals(KLIST_K);
149        assert_eq!(got, vec!["WORKSTATION1$@EXAMPLE.COM".to_string()]);
150    }
151
152    #[test]
153    fn empty_and_garbage_input() {
154        assert!(parse_machine_principals("").is_empty());
155        assert!(parse_machine_principals("no keytab entries here\n----\n").is_empty());
156        // header word in KVNO column must not match
157        assert!(parse_machine_principals("KVNO X$@R\n").is_empty());
158    }
159
160    #[test]
161    fn skips_service_principals_with_dollar_realm() {
162        // pathological: service principal whose instance ends in $
163        let got = parse_machine_principals("   3 host/weird$@REALM\n   3 GOOD$@REALM\n");
164        assert_eq!(got, vec!["GOOD$@REALM".to_string()]);
165    }
166
167    #[test]
168    fn rejects_leading_dash_principals() {
169        // a name that could be misparsed as a flag downstream is refused
170        let got = parse_machine_principals("   2 -x$@REALM\n   2 OK$@REALM\n");
171        assert_eq!(got, vec!["OK$@REALM".to_string()]);
172    }
173
174    #[test]
175    fn selects_principal_by_realm() {
176        let ps = vec!["WS1$@REALM.A".to_string(), "WS1$@REALM.B".to_string()];
177        // no realm -> first
178        assert_eq!(super::select_principal(&ps, None), Some(&ps[0]));
179        // realm match is case-insensitive
180        assert_eq!(super::select_principal(&ps, Some("realm.b")), Some(&ps[1]));
181        // no match -> None
182        assert_eq!(super::select_principal(&ps, Some("REALM.C")), None);
183        assert_eq!(super::select_principal(&[], None), None);
184    }
185}