Skip to main content

machine_krb/
ccache.rs

1use std::fs::{self, DirBuilder, Permissions};
2use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
3use std::path::{Component, Path, PathBuf};
4
5use crate::error::{Error, Result};
6use crate::exec::{self, Tools};
7use crate::identity::MachineIdentity;
8
9/// How `ensure` obtained a valid ticket.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize))]
12pub enum Freshness {
13    /// Existing ticket renewed in place (`kinit -R`) — same established
14    /// ticket, no fresh authentication against the KDC.
15    Renewed,
16    /// Brand-new ticket minted from the keytab (`kinit -k`). Note: a fresh
17    /// machine ticket can take a few seconds to "settle" before the KDC
18    /// honours it as FAST armor for compound authentication.
19    Minted,
20}
21
22/// A `FILE:` credential cache holding the machine (armor) ticket.
23#[derive(Debug, Clone)]
24pub struct ArmorCache {
25    path: PathBuf,
26}
27
28impl ArmorCache {
29    pub fn new(path: impl Into<PathBuf>) -> Self {
30        Self { path: path.into() }
31    }
32
33    pub fn path(&self) -> &Path {
34        &self.path
35    }
36
37    /// The `FILE:<path>` form kinit/klist/kdestroy expect.
38    pub fn cache_name(&self) -> String {
39        format!("FILE:{}", self.path.display())
40    }
41
42    /// Does the cache exist and hold a non-expired ticket? (`klist -s`)
43    pub fn is_valid(&self, tools: &Tools) -> bool {
44        exec::succeeds(&tools.klist, ["-s", &self.cache_name()])
45    }
46
47    /// Renew the existing ticket in place (`kinit -R`).
48    ///
49    /// No keytab and no fresh authentication needed. Renewal only works while
50    /// the current ticket is still **unexpired** (AD machine tickets live
51    /// ~10 h); the ~7-day renewable lifetime is the outer bound reachable via
52    /// *repeated pre-expiry renewals*, not a grace period after expiry — an
53    /// expired ticket always needs [`Self::mint`]. Use [`Self::ensure`] to get
54    /// the fallback automatically.
55    ///
56    /// The expected principal is passed explicitly, so a cache that somehow
57    /// holds a *different* principal fails here ("Matching credential not
58    /// found") instead of being silently renewed as the wrong identity.
59    pub fn renew(&self, tools: &Tools, identity: &MachineIdentity) -> Result<()> {
60        // "--" ends option parsing so the positional principal can never be
61        // misparsed as a flag (defense-in-depth; principals are validated too).
62        exec::run_ok(
63            &tools.kinit,
64            ["-R", "-c", &self.cache_name(), "--", &identity.principal],
65        )
66        .map(drop)
67    }
68
69    /// Mint a brand-new ticket from the machine keytab (`kinit -k`).
70    /// Needs read access to the keytab — root, in practice.
71    pub fn mint(&self, tools: &Tools, identity: &MachineIdentity) -> Result<()> {
72        exec::run_ok(
73            &tools.kinit,
74            [
75                "-k".as_ref(),
76                "-t".as_ref(),
77                identity.keytab.as_os_str(),
78                "-c".as_ref(),
79                self.cache_name().as_ref(),
80                "--".as_ref(), // end of options — see renew()
81                identity.principal.as_ref(),
82            ],
83        )
84        .map(drop)
85    }
86
87    /// Make sure the cache holds a valid ticket **for this identity**: renew
88    /// if possible (cheapest, keeps the same established ticket), mint from
89    /// the keytab otherwise. The result is verified with `klist -s` before
90    /// returning. A cache holding a foreign principal fails the renew and is
91    /// overwritten by the mint — the cache self-heals.
92    pub fn ensure(&self, tools: &Tools, identity: &MachineIdentity) -> Result<Freshness> {
93        if self.renew(tools, identity).is_ok() && self.is_valid(tools) {
94            return Ok(Freshness::Renewed);
95        }
96        self.mint(tools, identity)?;
97        if self.is_valid(tools) {
98            Ok(Freshness::Minted)
99        } else {
100            Err(Error::CacheStillInvalid(self.path.clone()))
101        }
102    }
103
104    /// Restrict the cache to `root:<gid>` 0640 so group members (and only
105    /// they) can use the armor ticket. kinit re-creates the file 0600 on
106    /// every renew/mint, so call this after `ensure`.
107    ///
108    /// Only the group is changed (owner stays whoever kinit ran as), so this
109    /// works under a minimal capability set (`CAP_CHOWN`). Race-free against
110    /// symlink swaps: the file is opened with `O_NOFOLLOW` and the ownership/
111    /// mode changes are applied through the file descriptor (`fchown`/
112    /// `fchmod`), so there is no check-to-use window on the path.
113    pub fn set_access(&self, gid: u32) -> Result<()> {
114        let file = fs::OpenOptions::new()
115            .read(true)
116            .custom_flags(libc::O_NOFOLLOW)
117            .open(&self.path)
118            .map_err(|e| {
119                // O_NOFOLLOW on a symlink -> ELOOP (ErrorKind::FilesystemLoop
120                // is still unstable, so match the raw errno).
121                if e.raw_os_error() == Some(libc::ELOOP) {
122                    Error::RefusingSymlink(self.path.clone())
123                } else {
124                    e.into()
125                }
126            })?;
127        std::os::unix::fs::fchown(&file, None, Some(gid))?;
128        file.set_permissions(Permissions::from_mode(0o640))?;
129        Ok(())
130    }
131
132    /// Raw `klist` text for status display (LC_ALL=C, so stable format).
133    pub fn klist_text(&self, tools: &Tools) -> Result<String> {
134        exec::run_ok(&tools.klist, [self.cache_name()])
135    }
136
137    /// Destroy the cache (best effort; missing cache is fine).
138    pub fn destroy(&self, tools: &Tools) {
139        let _ = exec::run(&tools.kdestroy, ["-c", &self.cache_name()]);
140        let _ = fs::remove_file(&self.path);
141    }
142}
143
144/// Create `dir` (if needed) 0750 so group members can reach the ticket cache
145/// inside; with `Some(gid)`, hand the directory to that group. This must only
146/// ever manage a **dedicated** directory like `/run/machine-krb`, so it refuses:
147/// relative paths, paths containing `..`, a symlink where the directory
148/// should be, and (post-canonicalization) well-known shared directories.
149pub fn prepare_dir(dir: &Path, gid: Option<u32>) -> Result<()> {
150    if !dir.is_absolute()
151        || dir.components().any(|c| c == Component::ParentDir)
152        || is_shared_dir(dir)
153    {
154        return Err(Error::RefusingSharedDir(dir.to_path_buf()));
155    }
156    match fs::symlink_metadata(dir) {
157        Ok(meta) if meta.file_type().is_symlink() => {
158            return Err(Error::RefusingSymlink(dir.to_path_buf()));
159        }
160        Ok(_) => {}
161        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
162            DirBuilder::new().recursive(true).mode(0o750).create(dir)?;
163        }
164        Err(e) => return Err(e.into()),
165    }
166    // Canonicalize (resolves intermediate symlinks like /var/run or, on
167    // ostree, /usr/local) before deciding whether this is a shared dir.
168    let real = fs::canonicalize(dir)?;
169    if is_shared_dir(&real) {
170        return Err(Error::RefusingSharedDir(dir.to_path_buf()));
171    }
172    if let Some(gid) = gid {
173        std::os::unix::fs::chown(&real, None, Some(gid))?;
174    }
175    fs::set_permissions(&real, Permissions::from_mode(0o750))?;
176    Ok(())
177}
178
179/// Directories that must never have their ownership/mode taken over.
180fn is_shared_dir(canonical: &Path) -> bool {
181    const SHARED: &[&str] = &[
182        "/",
183        "/run",
184        "/var/run",
185        "/run/user",
186        "/tmp",
187        "/var/tmp",
188        "/var",
189        "/etc",
190        "/home",
191        "/root",
192        "/usr",
193        "/usr/local",
194        "/usr/local/bin",
195        "/var/usrlocal",
196        "/var/lib",
197        "/opt",
198        "/srv",
199        "/boot",
200        "/dev",
201        "/dev/shm",
202    ];
203    // Anything that shallow (e.g. /run, /var/lib) is by definition not a
204    // dedicated leaf dir; the explicit list catches the named ones.
205    SHARED.iter().any(|s| Path::new(s) == canonical)
206}
207
208/// Resolve a group name to its gid via `getent group` (covers local files
209/// and SSSD/NSS-provided groups alike).
210pub fn lookup_gid(tools: &Tools, group: &str) -> Result<u32> {
211    // "--" so a group name starting with '-' can't be misparsed as a flag.
212    let line = exec::run(&tools.getent, ["--", "group", group])?;
213    if !line.status.success() {
214        return Err(Error::GroupNotFound(group.to_string()));
215    }
216    parse_getent_gid(&String::from_utf8_lossy(&line.stdout))
217        .ok_or_else(|| Error::GroupNotFound(group.to_string()))
218}
219
220/// Parse the gid out of a `getent group` line: `name:x:GID:members`.
221pub(crate) fn parse_getent_gid(line: &str) -> Option<u32> {
222    line.trim().split(':').nth(2)?.parse().ok()
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn getent_gid_parses() {
231        assert_eq!(parse_getent_gid("machine-krb:x:973:dylan\n"), Some(973));
232        assert_eq!(parse_getent_gid("wheel:x:10:"), Some(10));
233        assert_eq!(parse_getent_gid(""), None);
234        assert_eq!(parse_getent_gid("garbage"), None);
235        assert_eq!(parse_getent_gid("a:b"), None);
236    }
237
238    #[test]
239    fn prepare_dir_refuses_shared_dirs() {
240        for d in ["/run", "/tmp", "/", "/var", "/etc", "/usr/local"] {
241            assert!(matches!(
242                prepare_dir(Path::new(d), None),
243                Err(Error::RefusingSharedDir(_))
244            ));
245        }
246    }
247
248    #[test]
249    fn prepare_dir_refuses_dotdot_and_relative() {
250        for d in [
251            "/run/machine-krb/..",
252            "/run/../run",
253            "run/machine-krb",
254            "../x",
255        ] {
256            assert!(matches!(
257                prepare_dir(Path::new(d), None),
258                Err(Error::RefusingSharedDir(_))
259            ));
260        }
261    }
262
263    #[test]
264    fn shared_dir_list_hits_canonical_forms() {
265        for d in ["/var/usrlocal", "/run/user", "/var/lib", "/opt"] {
266            assert!(is_shared_dir(Path::new(d)));
267        }
268        assert!(!is_shared_dir(Path::new("/run/machine-krb")));
269    }
270
271    #[test]
272    fn cache_name_is_file_prefixed() {
273        let c = ArmorCache::new("/run/machine-krb/armor.ccache");
274        assert_eq!(c.cache_name(), "FILE:/run/machine-krb/armor.ccache");
275    }
276}