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