Skip to main content

majordomus_cli/mesh/
trust.rs

1//! Trust: what a verified advertisement is allowed to mean. Verification (the signature
2//! proves the key) happens in the protocol; trust (this key is one of ours) is decided
3//! here, by policy — and by default the policy is that an unknown key is observed,
4//! recorded, visible in every listing, and trusted for nothing.
5//!
6//! Discovery creates awareness, not authority: even a `Trusted` node gains no execution
7//! rights anywhere in this codebase. Trust gates which nodes surfaces mark as ours and
8//! which endpoints later negotiation may approach — nothing else.
9//!
10//! ```
11//! use majordomus_cli::mesh::trust::{evaluate, TrustPolicy, TrustState};
12//!
13//! // the default: a valid unknown key is observed and trusted for nothing
14//! assert_eq!(evaluate(&TrustPolicy::DenyUnknown, &[], "aa", None, None), TrustState::Observed);
15//! // an allowlisted key is trusted under every policy
16//! let listed = evaluate(&TrustPolicy::DenyUnknown, &["aa".into()], "aa", None, None);
17//! assert!(listed.is_trusted());
18//! ```
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23/// The policy, from the mesh declaration.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "snake_case")]
26pub enum TrustPolicy {
27    /// The default: a valid unknown node is recorded as observed and trusted for
28    /// nothing. The safe answer when nobody decided otherwise.
29    #[default]
30    DenyUnknown,
31    /// Trust-on-first-use: the first valid appearance of a key is trusted, a later
32    /// appearance of the same node id under a different key is rejected. A development
33    /// convenience, and every listing says the trust came from TOFU rather than a person.
34    Tofu,
35    /// Only keys the declaration lists are trusted; everything else is observed.
36    Allowlist,
37}
38
39impl TrustPolicy {
40    /// The policy's wire word, as declarations write it and listings show it.
41    ///
42    /// ```
43    /// use majordomus_cli::mesh::trust::TrustPolicy;
44    /// assert_eq!(TrustPolicy::DenyUnknown.as_str(), "deny_unknown");
45    /// ```
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            TrustPolicy::DenyUnknown => "deny_unknown",
49            TrustPolicy::Tofu => "tofu",
50            TrustPolicy::Allowlist => "allowlist",
51        }
52    }
53}
54
55/// What the policy decided about one node.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
58pub enum TrustState {
59    /// Valid, recorded, trusted for nothing. The resting state of an unknown node.
60    Observed,
61    /// The policy trusts this key; `detail` says which rule did (`allowlist`, `tofu`).
62    Trusted(String),
63    /// The policy rejected this node; `detail` says why (for instance a TOFU key
64    /// change). A rejected node stays listed — an operator debugging a mesh needs to
65    /// see what was refused, not wonder about silence.
66    Rejected(String),
67}
68
69impl TrustState {
70    /// Whether this state trusts the node — the one question consumers ask; the detail
71    /// stays on the variant for whoever renders it.
72    pub fn is_trusted(&self) -> bool {
73        matches!(self, TrustState::Trusted(_))
74    }
75}
76
77/// Evaluate the policy for a node's key against what the registry already knows.
78///
79/// ```
80/// use majordomus_cli::mesh::trust::{evaluate, TrustPolicy, TrustState};
81/// // TOFU trusts first use; a key change under a known node id is rejected regardless
82/// assert!(evaluate(&TrustPolicy::Tofu, &[], "aa", None, None).is_trusted());
83/// assert!(matches!(
84///     evaluate(&TrustPolicy::Tofu, &[], "bb", Some("aa"), None),
85///     TrustState::Rejected(_)
86/// ));
87/// ```
88///
89/// `known_key` is the key the registry last associated with this node id — with node
90/// ids derived from keys the two cannot disagree, so a mismatch can only mean a digest
91/// collision or registry corruption, and it is rejected loudly.
92pub fn evaluate(
93    policy: &TrustPolicy,
94    allow: &[String],
95    public_key: &str,
96    known_key: Option<&str>,
97    previous: Option<&TrustState>,
98) -> TrustState {
99    if let Some(known) = known_key {
100        if known != public_key {
101            return TrustState::Rejected("the node id is already bound to a different key".into());
102        }
103    }
104    if allow.iter().any(|k| k == public_key) {
105        return TrustState::Trusted("allowlist".into());
106    }
107    match policy {
108        TrustPolicy::DenyUnknown => TrustState::Observed,
109        TrustPolicy::Allowlist => TrustState::Observed,
110        TrustPolicy::Tofu => match previous {
111            // The first use is what TOFU trusts; a node once rejected stays rejected.
112            Some(state @ TrustState::Rejected(_)) => state.clone(),
113            _ => TrustState::Trusted("tofu".into()),
114        },
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn deny_unknown_observes_and_never_trusts() {
124        let state = evaluate(&TrustPolicy::DenyUnknown, &[], "aa", None, None);
125        assert_eq!(state, TrustState::Observed);
126        assert!(!state.is_trusted());
127    }
128
129    #[test]
130    fn the_allowlist_trusts_listed_keys_under_every_policy() {
131        for policy in [
132            TrustPolicy::DenyUnknown,
133            TrustPolicy::Tofu,
134            TrustPolicy::Allowlist,
135        ] {
136            let state = evaluate(&policy, &["aa".into()], "aa", None, None);
137            assert_eq!(state, TrustState::Trusted("allowlist".into()), "{policy:?}");
138        }
139    }
140
141    #[test]
142    fn allowlist_policy_observes_the_unlisted() {
143        let state = evaluate(&TrustPolicy::Allowlist, &["aa".into()], "bb", None, None);
144        assert_eq!(state, TrustState::Observed);
145    }
146
147    #[test]
148    fn tofu_trusts_first_use_and_keeps_a_rejection() {
149        let first = evaluate(&TrustPolicy::Tofu, &[], "aa", None, None);
150        assert_eq!(first, TrustState::Trusted("tofu".into()));
151        let rejected = TrustState::Rejected("test".into());
152        let after = evaluate(&TrustPolicy::Tofu, &[], "aa", Some("aa"), Some(&rejected));
153        assert_eq!(
154            after, rejected,
155            "a rejection is not washed away by reappearing"
156        );
157    }
158
159    #[test]
160    fn a_key_change_under_one_node_id_is_rejected() {
161        let state = evaluate(&TrustPolicy::Tofu, &[], "bb", Some("aa"), None);
162        assert!(matches!(state, TrustState::Rejected(_)));
163    }
164}