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}