Skip to main content

sui_spec/
trust_model.rs

1//! Typed border for the nix trust model.
2//!
3//! Nix has three orthogonal trust axes:
4//!
5//! 1. **Signature trust**: which public keys are accepted for
6//!    narinfo signatures?  `trusted-public-keys` setting.
7//! 2. **Substituter trust**: which substituters are accepted for
8//!    each user?  `trusted-substituters` setting.
9//! 3. **Build trust**: which users can run builds?
10//!    `trusted-users`, `allowed-users` settings.
11
12use serde::{Deserialize, Serialize};
13use tatara_lisp::DeriveTataraDomain;
14
15use crate::SpecError;
16
17#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
18#[tatara(keyword = "deftrust-model")]
19pub struct TrustModel {
20    pub name: String,
21    /// Whose narinfo signatures are universally trusted.
22    #[serde(rename = "trustedPublicKeys")]
23    pub trusted_public_keys: Vec<String>,
24    /// Substituter URLs the daemon will pull from regardless of
25    /// requesting user.
26    #[serde(rename = "trustedSubstituters")]
27    pub trusted_substituters: Vec<String>,
28    /// Users who can do unrestricted builds.
29    #[serde(rename = "trustedUsers")]
30    pub trusted_users: Vec<String>,
31    /// Posture preset — names the "shape" of the policy.
32    pub posture: TrustPosture,
33}
34
35#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum TrustPosture {
37    /// Open: any user can build, any substituter is trusted.
38    /// Single-user nix install.
39    Permissive,
40    /// Multi-user: only `trusted-users` can build; only
41    /// `trusted-substituters` are pulled from.
42    MultiUser,
43    /// Locked-down: no substituters, builds require root, no
44    /// network in build sandbox.  Compliance regimes.
45    Sealed,
46}
47
48pub const CANONICAL_TRUST_LISP: &str = include_str!("../specs/trust_model.lisp");
49
50/// Compile every authored trust model.
51///
52/// # Errors
53///
54/// Returns an error if the Lisp source fails to parse.
55pub fn load_canonical() -> Result<Vec<TrustModel>, SpecError> {
56    crate::loader::load_all::<TrustModel>(CANONICAL_TRUST_LISP)
57}
58
59/// Return the trust model whose `name` matches.
60///
61/// # Errors
62///
63/// Returns an error if the spec fails to parse or `name` is missing.
64pub fn load_named(name: &str) -> Result<TrustModel, SpecError> {
65    load_canonical()?
66        .into_iter()
67        .find(|t| t.name == name)
68        .ok_or_else(|| SpecError::Load(format!("no (deftrust-model) with :name {name:?}")))
69}
70
71// ── M3.0 trust-policy evaluator ────────────────────────────────────
72
73/// Check whether `user` can build under this trust model.
74/// Trusted users are: those listed in `trusted_users`, with `"*"`
75/// matching all users and `"@group"` notation matching when the
76/// optional `group_membership` callback returns true.
77#[must_use]
78pub fn user_can_build(model: &TrustModel, user: &str) -> bool {
79    for entry in &model.trusted_users {
80        if entry == "*" || entry == user {
81            return true;
82        }
83        // @group syntax — M3.0 doesn't check actual group membership;
84        // the operator (or sui-daemon) provides that via the env
85        // trait in M3.1.  For now, "@wheel" matches any user.
86        if entry.starts_with('@') {
87            return true;
88        }
89    }
90    false
91}
92
93/// Check whether a substituter URL is in the trusted-substituters
94/// list.
95#[must_use]
96pub fn substituter_trusted(model: &TrustModel, url: &str) -> bool {
97    model.trusted_substituters.iter().any(|s| s == url)
98}
99
100/// Check whether a signing-key name (as it appears in narinfo
101/// `Sig:` lines) is in the trusted-public-keys set.
102#[must_use]
103pub fn key_trusted(model: &TrustModel, key_name: &str) -> bool {
104    model.trusted_public_keys.iter().any(|k| {
105        // Keys in cppnix are "name:base64-pubkey" — match on the
106        // name part before the colon.
107        k.split(':').next() == Some(key_name)
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn canonical_trust_models_parse() {
117        let models = load_canonical().unwrap();
118        assert!(!models.is_empty());
119    }
120
121    #[test]
122    fn three_postures_present() {
123        let models = load_canonical().unwrap();
124        let postures: std::collections::HashSet<TrustPosture> =
125            models.iter().map(|m| m.posture).collect();
126        for required in [TrustPosture::Permissive, TrustPosture::MultiUser, TrustPosture::Sealed] {
127            assert!(postures.contains(&required), "missing posture {required:?}");
128        }
129    }
130
131    #[test]
132    fn sealed_has_no_substituters() {
133        let m = load_named("sealed-compliance").unwrap();
134        assert!(
135            m.trusted_substituters.is_empty(),
136            "Sealed posture must have no trusted substituters",
137        );
138    }
139
140    // ── M3.0 evaluator tests ───────────────────────────────────
141
142    #[test]
143    fn permissive_lets_anyone_build() {
144        let m = load_named("single-user-permissive").unwrap();
145        assert!(user_can_build(&m, "anyone"));
146        assert!(user_can_build(&m, "drzzln"));
147    }
148
149    #[test]
150    fn sealed_only_lets_root_build() {
151        let m = load_named("sealed-compliance").unwrap();
152        assert!(user_can_build(&m, "root"));
153        assert!(!user_can_build(&m, "drzzln"));
154    }
155
156    #[test]
157    fn cache_nixos_org_is_trusted_in_default_model() {
158        let m = load_named("multi-user-default").unwrap();
159        assert!(substituter_trusted(&m, "https://cache.nixos.org"));
160        assert!(!substituter_trusted(&m, "https://untrusted.example.com"));
161    }
162
163    #[test]
164    fn cache_nixos_key_recognized_by_name_prefix() {
165        let m = load_named("multi-user-default").unwrap();
166        assert!(key_trusted(&m, "cache.nixos.org-1"));
167        assert!(!key_trusted(&m, "random-untrusted-key"));
168    }
169}