Skip to main content

capsule_runtime/
trust_store.rs

1//! The host's TLS trust store, found at staging for a capsule whose commands reach the network.
2//!
3//! On `scoped` a command's filesystem reach is a Landlock ruleset, and a path with no rule is
4//! denied. An allowlisted `curl`, `git` or `python3` that may open a TLS connection still has to
5//! read the certificates that verify the server, so without a rule for them it fails certificate
6//! verification over an otherwise-working connection. Where a host keeps them differs by
7//! distribution, so the runtime checks a fixed table of well-known locations against this host and
8//! grants what exists (see [`crate::sandbox::resolve_trust_store_grants`]): readable and listable,
9//! never executable, never writable.
10//!
11//! Nothing is derived unless the capsule allowlists a binary *and* declares a network allowlist:
12//! a command that cannot reach the network has no certificate to verify. No subprocess is run and
13//! `SSL_CERT_FILE` / `SSL_CERT_DIR` are not read: they are `mur`'s own environment, which the
14//! capsule's commands do not receive, and they can name any file. A trust store outside the table
15//! is reachable through `capabilities.shell.interpreter_runtime`.
16
17use std::path::{Path, PathBuf};
18
19/// One host path staging granted read-only: a trust-store location, or a path a tool's declared
20/// program need resolved to ([`crate::declared_needs::NeedPath`]).
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
22pub struct TrustStorePath {
23    /// Canonical: symlinks resolved, so the rule names the inode the host really keeps.
24    pub path: PathBuf,
25    /// Whether `path` is a directory, which decides whether its grant is listable.
26    pub directory: bool,
27}
28
29impl TrustStorePath {
30    /// `<path>`, or `<path> (list_dir)` for a directory: how every report spells the grant.
31    #[must_use]
32    pub fn render(&self) -> String {
33        let listable = if self.directory { " (list_dir)" } else { "" };
34        format!("{}{listable}", self.path.display())
35    }
36}
37
38/// One well-known trust-store location: a CA bundle file or a hashed certificate directory.
39#[cfg(target_os = "linux")]
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub(crate) struct TrustStoreCandidate<'a> {
42    /// Absolute, as a distribution spells it; possibly a symlink.
43    pub(crate) path: &'a str,
44    /// Whether the location is a directory; a candidate that turns out to be the other kind is
45    /// not the trust store and contributes nothing.
46    pub(crate) directory: bool,
47}
48
49/// Every location the runtime checks, in order: the union of the Linux lists the Go standard
50/// library (`crypto/x509/root_linux.go`) and the `openssl-probe` crate search. To add one, name a
51/// path a distribution ships its CA bundle or hashed certificate directory at; never a directory
52/// that also holds keys or configuration, because a directory grant covers everything beneath it.
53#[cfg(target_os = "linux")]
54pub(crate) const TRUST_STORE_CANDIDATES: &[TrustStoreCandidate<'static>] = &[
55    // Debian, Ubuntu, Arch, Gentoo.
56    TrustStoreCandidate {
57        path: "/etc/ssl/certs/ca-certificates.crt",
58        directory: false,
59    },
60    // Fedora, RHEL.
61    TrustStoreCandidate {
62        path: "/etc/pki/tls/certs/ca-bundle.crt",
63        directory: false,
64    },
65    // RHEL 7+, Fedora, CentOS.
66    TrustStoreCandidate {
67        path: "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
68        directory: false,
69    },
70    // openSUSE.
71    TrustStoreCandidate {
72        path: "/etc/ssl/ca-bundle.pem",
73        directory: false,
74    },
75    // OpenELEC.
76    TrustStoreCandidate {
77        path: "/etc/pki/tls/cacert.pem",
78        directory: false,
79    },
80    // Alpine.
81    TrustStoreCandidate {
82        path: "/etc/ssl/cert.pem",
83        directory: false,
84    },
85    // Debian's OpenSSL default `OPENSSLDIR`, a symlink to the Debian bundle.
86    TrustStoreCandidate {
87        path: "/usr/lib/ssl/cert.pem",
88        directory: false,
89    },
90    // Debian, Ubuntu, Alpine, Arch: the hashed `CApath`.
91    TrustStoreCandidate {
92        path: "/etc/ssl/certs",
93        directory: true,
94    },
95    // Fedora, RHEL.
96    TrustStoreCandidate {
97        path: "/etc/pki/tls/certs",
98        directory: true,
99    },
100    // RHEL 7+, Fedora.
101    TrustStoreCandidate {
102        path: "/etc/pki/ca-trust/extracted/pem",
103        directory: true,
104    },
105    // Debian's OpenSSL default `CApath`, a symlink to `/etc/ssl/certs`.
106    TrustStoreCandidate {
107        path: "/usr/lib/ssl/certs",
108        directory: true,
109    },
110    // openSUSE.
111    TrustStoreCandidate {
112        path: "/var/lib/ca-certificates/pem",
113        directory: true,
114    },
115];
116
117/// The trust-store paths this host has, for a capsule allowlisting `shell_allow` and reaching
118/// `network_allow`: empty unless both are non-empty.
119///
120/// `capsule_writable` are the trees the capsule's commands can write
121/// ([`crate::interpreter_runtime::capsule_writable_roots`]); nothing under one is derived. Sorted
122/// by path and deduplicated, so every report of the set agrees.
123///
124/// Reads the filesystem only, runs nothing, and fails nothing: a location that is missing,
125/// dangling or unreadable contributes nothing.
126///
127/// Off Linux this returns nothing: commands' file reads are not path-scoped there, so there is no
128/// rule to feed.
129pub fn derive_trust_store(
130    shell_allow: &[String],
131    network_allow: &[String],
132    capsule_writable: &[&Path],
133) -> Vec<TrustStorePath> {
134    #[cfg(target_os = "linux")]
135    {
136        derive_in(
137            TRUST_STORE_CANDIDATES,
138            shell_allow,
139            network_allow,
140            capsule_writable,
141        )
142    }
143    #[cfg(not(target_os = "linux"))]
144    {
145        let _ = (shell_allow, network_allow, capsule_writable);
146        Vec::new()
147    }
148}
149
150/// [`derive_trust_store`] with the candidate table injected.
151///
152///   - A candidate is kept when it canonicalizes to its own kind: a directory for a directory
153///     candidate, a regular file for a file candidate.
154///   - Each symlink directly inside a kept directory that resolves to a regular file outside every
155///     kept directory makes that file's parent a listable grant, unless the parent is `/`, an
156///     ancestor of (or equal to) a kept path, or under a capsule-writable root. This is where
157///     Debian's `/etc/ssl/certs/*.pem` reach `/usr/share/ca-certificates/mozilla`, and why a link
158///     to `/etc/ssl/openssl.cnf` cannot widen the grant to `/etc/ssl` and its `private/`.
159///   - A kept file inside a granted directory is dropped as redundant.
160///   - Nothing that is `/` or under a capsule-writable root is returned.
161#[cfg(target_os = "linux")]
162pub(crate) fn derive_in(
163    candidates: &[TrustStoreCandidate<'_>],
164    shell_allow: &[String],
165    network_allow: &[String],
166    capsule_writable: &[&Path],
167) -> Vec<TrustStorePath> {
168    if shell_allow.is_empty() || network_allow.is_empty() {
169        return Vec::new();
170    }
171    select_in(candidates, capsule_writable)
172}
173
174/// The trust-store paths this host has among `candidates`, with no network gate: [`derive_in`]
175/// past its gate, and what a tool's `system: ca_bundle` need resolves to
176/// ([`crate::declared_needs`]). The rules are [`derive_in`]'s.
177#[cfg(target_os = "linux")]
178pub(crate) fn select_in(
179    candidates: &[TrustStoreCandidate<'_>],
180    capsule_writable: &[&Path],
181) -> Vec<TrustStorePath> {
182    let roots: Vec<PathBuf> = capsule_writable
183        .iter()
184        .map(|root| crate::protected_paths::canonicalize_existing_prefix(root))
185        .collect();
186    let grantable =
187        |path: &Path| path != Path::new("/") && !crate::reachability::under_any(path, &roots);
188
189    let mut dirs: Vec<PathBuf> = Vec::new();
190    let mut files: Vec<PathBuf> = Vec::new();
191    for candidate in candidates {
192        let Ok(canonical) = std::fs::canonicalize(candidate.path) else {
193            continue;
194        };
195        let Ok(metadata) = std::fs::metadata(&canonical) else {
196            continue;
197        };
198        if !grantable(&canonical) {
199            continue;
200        }
201        if candidate.directory && metadata.is_dir() {
202            dirs.push(canonical);
203        } else if !candidate.directory && metadata.is_file() {
204            files.push(canonical);
205        }
206    }
207
208    let inside_kept_dir = |path: &Path| dirs.iter().any(|dir| path.starts_with(dir));
209    let encloses_kept_path = |parent: &Path| {
210        dirs.iter()
211            .chain(files.iter())
212            .any(|kept| kept.starts_with(parent))
213    };
214    let mut linked_dirs: Vec<PathBuf> = Vec::new();
215    for dir in &dirs {
216        let Ok(listing) = std::fs::read_dir(dir) else {
217            continue;
218        };
219        for entry in listing.flatten() {
220            let path = entry.path();
221            if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) {
222                continue;
223            }
224            let Ok(target) = std::fs::canonicalize(&path) else {
225                continue;
226            };
227            if !std::fs::metadata(&target).is_ok_and(|m| m.is_file()) || inside_kept_dir(&target) {
228                continue;
229            }
230            let Some(parent) = target.parent() else {
231                continue;
232            };
233            if grantable(parent) && !encloses_kept_path(parent) {
234                linked_dirs.push(parent.to_path_buf());
235            }
236        }
237    }
238
239    let mut derived: Vec<TrustStorePath> = dirs
240        .iter()
241        .chain(linked_dirs.iter())
242        .map(|path| TrustStorePath {
243            path: path.clone(),
244            directory: true,
245        })
246        .collect();
247    derived.extend(
248        files
249            .into_iter()
250            .filter(|file| {
251                !dirs
252                    .iter()
253                    .chain(linked_dirs.iter())
254                    .any(|dir| file.starts_with(dir))
255            })
256            .map(|path| TrustStorePath {
257                path,
258                directory: false,
259            }),
260    );
261    derived.sort_by(|a, b| a.path.cmp(&b.path));
262    derived.dedup_by(|a, b| a.path == b.path);
263    derived
264}
265
266#[cfg(all(test, target_os = "linux"))]
267mod tests {
268    use super::*;
269    use std::os::unix::fs::symlink;
270
271    fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
272        let temp = tempfile::tempdir().unwrap();
273        let root = std::fs::canonicalize(temp.path()).unwrap();
274        (temp, root)
275    }
276
277    fn allow() -> Vec<String> {
278        vec!["curl".to_string()]
279    }
280
281    fn network() -> Vec<String> {
282        vec!["example.com".to_string()]
283    }
284
285    /// Each path as a `(path, directory)` candidate, borrowing `paths`.
286    fn candidates(paths: &[(String, bool)]) -> Vec<TrustStoreCandidate<'_>> {
287        paths
288            .iter()
289            .map(|(path, directory)| TrustStoreCandidate {
290                path,
291                directory: *directory,
292            })
293            .collect()
294    }
295
296    fn spec(path: &Path, directory: bool) -> (String, bool) {
297        (path.to_str().unwrap().to_string(), directory)
298    }
299
300    fn dir(path: &Path) -> TrustStorePath {
301        TrustStorePath {
302            path: path.to_path_buf(),
303            directory: true,
304        }
305    }
306
307    fn file(path: &Path) -> TrustStorePath {
308        TrustStorePath {
309            path: path.to_path_buf(),
310            directory: false,
311        }
312    }
313
314    fn write(path: &Path, contents: &str) {
315        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
316        std::fs::write(path, contents).unwrap();
317    }
318
319    /// A Debian-shaped store under `root`: `etc/ssl/certs` holding a bundle, a `.pem` link into
320    /// `share/mozilla` and a hashed link to that `.pem`, beside `etc/ssl/openssl.cnf` and
321    /// `etc/ssl/private/`. Returns `etc/ssl/certs`.
322    fn debian_layout(root: &Path) -> PathBuf {
323        let certs = root.join("etc/ssl/certs");
324        std::fs::create_dir_all(&certs).unwrap();
325        std::fs::create_dir_all(root.join("etc/ssl/private")).unwrap();
326        write(&root.join("etc/ssl/openssl.cnf"), "[openssl_init]\n");
327        write(
328            &root.join("share/mozilla/X.crt"),
329            "-----BEGIN CERTIFICATE-----\n",
330        );
331        write(
332            &certs.join("ca-certificates.crt"),
333            "-----BEGIN CERTIFICATE-----\n",
334        );
335        symlink("../../../share/mozilla/X.crt", certs.join("X.pem")).unwrap();
336        symlink("X.pem", certs.join("abcd1234.0")).unwrap();
337        certs
338    }
339
340    #[test]
341    fn nothing_is_derived_without_both_a_shell_and_a_network_allowlist() {
342        let (_temp, root) = canonical_tempdir();
343        let certs = debian_layout(&root);
344        let table = [spec(&certs, true)];
345        let table = candidates(&table);
346        assert!(derive_in(&table, &[], &network(), &[]).is_empty());
347        assert!(derive_in(&table, &allow(), &[], &[]).is_empty());
348        assert!(derive_in(&table, &[], &[], &[]).is_empty());
349        assert!(!derive_in(&table, &allow(), &network(), &[]).is_empty());
350        // The production table under the same gate.
351        assert!(derive_trust_store(&[], &network(), &[]).is_empty());
352        assert!(derive_trust_store(&allow(), &[], &[]).is_empty());
353    }
354
355    #[test]
356    fn missing_and_wrong_kind_candidates_contribute_nothing() {
357        let (_temp, root) = canonical_tempdir();
358        let a_dir = root.join("a-dir");
359        let a_file = root.join("a-file.pem");
360        std::fs::create_dir_all(&a_dir).unwrap();
361        write(&a_file, "x");
362        symlink(root.join("nowhere"), root.join("dangling")).unwrap();
363        let table = [
364            spec(&root.join("missing"), true),
365            spec(&root.join("missing.pem"), false),
366            spec(&root.join("dangling"), false),
367            spec(&a_dir, false),
368            spec(&a_file, true),
369        ];
370        assert!(derive_in(&candidates(&table), &allow(), &network(), &[]).is_empty());
371    }
372
373    #[test]
374    fn a_candidate_directory_is_granted_listable_at_its_canonical_path() {
375        let (_temp, root) = canonical_tempdir();
376        let real = root.join("etc/pki/tls/certs");
377        std::fs::create_dir_all(&real).unwrap();
378        let alias = root.join("usr-lib-ssl-certs");
379        symlink(&real, &alias).unwrap();
380        let table = [spec(&alias, true), spec(&real, true)];
381        assert_eq!(
382            derive_in(&candidates(&table), &allow(), &network(), &[]),
383            vec![dir(&real)]
384        );
385    }
386
387    #[test]
388    fn certificate_links_grant_the_directory_they_point_into() {
389        let (_temp, root) = canonical_tempdir();
390        let certs = debian_layout(&root);
391        let table = [spec(&certs, true)];
392        assert_eq!(
393            derive_in(&candidates(&table), &allow(), &network(), &[]),
394            vec![dir(&certs), dir(&root.join("share/mozilla"))]
395        );
396    }
397
398    #[test]
399    fn a_link_to_a_sibling_of_the_store_does_not_widen_the_grant_to_its_parent() {
400        let (_temp, root) = canonical_tempdir();
401        let certs = debian_layout(&root);
402        symlink("../openssl.cnf", certs.join("evil")).unwrap();
403        symlink("../private", certs.join("keys")).unwrap();
404        let bundle = root.join("etc/ssl/cert.pem");
405        write(&bundle, "-----BEGIN CERTIFICATE-----\n");
406        symlink("../cert.pem", certs.join("bundle-link")).unwrap();
407        let table = [spec(&bundle, false), spec(&certs, true)];
408        let derived = derive_in(&candidates(&table), &allow(), &network(), &[]);
409        assert_eq!(
410            derived,
411            vec![file(&bundle), dir(&certs), dir(&root.join("share/mozilla"))]
412        );
413        for path in derived.iter().map(|entry| &entry.path) {
414            assert_ne!(path, &root.join("etc/ssl"));
415            assert!(!path.starts_with(root.join("etc/ssl/private")));
416            assert!(!certs.starts_with(path) || path == &certs, "{path:?}");
417        }
418    }
419
420    #[test]
421    fn links_into_writable_roots_dangling_links_and_directory_links_are_dropped() {
422        let (_temp, root) = canonical_tempdir();
423        let certs = root.join("etc/ssl/certs");
424        std::fs::create_dir_all(&certs).unwrap();
425        let work = root.join("work");
426        write(&work.join("planted.crt"), "x");
427        std::fs::create_dir_all(root.join("share/subdir")).unwrap();
428        symlink(work.join("planted.crt"), certs.join("planted.pem")).unwrap();
429        symlink(root.join("gone.crt"), certs.join("dangling.pem")).unwrap();
430        symlink(root.join("share/subdir"), certs.join("subdir")).unwrap();
431        let fifo = root.join("share/fifo");
432        let status = std::process::Command::new("mkfifo")
433            .arg(&fifo)
434            .status()
435            .unwrap();
436        assert!(status.success());
437        symlink(&fifo, certs.join("fifo.pem")).unwrap();
438        let table = [spec(&certs, true)];
439        assert_eq!(
440            derive_in(&candidates(&table), &allow(), &network(), &[&work]),
441            vec![dir(&certs)]
442        );
443    }
444
445    #[test]
446    fn a_candidate_under_a_writable_root_is_dropped() {
447        let (_temp, root) = canonical_tempdir();
448        let work = root.join("work");
449        let certs = work.join("certs");
450        std::fs::create_dir_all(&certs).unwrap();
451        write(&work.join("bundle.pem"), "x");
452        let table = [spec(&certs, true), spec(&work.join("bundle.pem"), false)];
453        assert!(derive_in(&candidates(&table), &allow(), &network(), &[&work]).is_empty());
454    }
455
456    #[test]
457    fn a_file_candidate_inside_a_granted_directory_is_listed_once() {
458        let (_temp, root) = canonical_tempdir();
459        let certs = debian_layout(&root);
460        let inside = certs.join("ca-certificates.crt");
461        let alias = root.join("usr-lib-ssl-cert.pem");
462        symlink(&inside, &alias).unwrap();
463        let outside = root.join("etc/pki/tls/cacert.pem");
464        write(&outside, "x");
465        let table = [
466            spec(&inside, false),
467            spec(&alias, false),
468            spec(&outside, false),
469            spec(&certs, true),
470        ];
471        assert_eq!(
472            derive_in(&candidates(&table), &allow(), &network(), &[]),
473            vec![
474                file(&outside),
475                dir(&certs),
476                dir(&root.join("share/mozilla"))
477            ]
478        );
479    }
480
481    #[test]
482    fn the_result_is_sorted_and_deduplicated() {
483        let (_temp, root) = canonical_tempdir();
484        let b = root.join("b/certs");
485        let a = root.join("a/certs");
486        std::fs::create_dir_all(&a).unwrap();
487        std::fs::create_dir_all(&b).unwrap();
488        write(&root.join("store/1.crt"), "x");
489        write(&root.join("store/2.crt"), "x");
490        symlink(root.join("store/1.crt"), a.join("1.pem")).unwrap();
491        symlink(root.join("store/2.crt"), b.join("2.pem")).unwrap();
492        let table = [spec(&b, true), spec(&a, true), spec(&b, true)];
493        assert_eq!(
494            derive_in(&candidates(&table), &allow(), &network(), &[]),
495            vec![dir(&a), dir(&b), dir(&root.join("store"))]
496        );
497    }
498
499    #[test]
500    fn the_candidate_table_is_absolute_and_names_no_key_or_config_directory() {
501        assert_eq!(TRUST_STORE_CANDIDATES.len(), 12);
502        for candidate in TRUST_STORE_CANDIDATES {
503            let path = Path::new(candidate.path);
504            assert!(path.is_absolute(), "{path:?}");
505            assert!(
506                !path.components().any(|c| c.as_os_str() == "private"),
507                "{path:?}"
508            );
509            for broad in [
510                "/",
511                "/etc",
512                "/etc/ssl",
513                "/etc/pki",
514                "/etc/pki/tls",
515                "/usr/lib/ssl",
516            ] {
517                assert_ne!(path, Path::new(broad));
518            }
519        }
520    }
521}