Skip to main content

lean_ctx/core/policy/org/
trust.rs

1//! Trust anchors for org policy distribution (GL #674).
2//!
3//! A signed [`super::OrgPolicyV1`] is only honoured when its signing key is one
4//! the endpoint has **pinned out-of-band** — exactly the SSH-`known_hosts` /
5//! certificate-pinning model. Pinning is what makes central distribution
6//! *un-bypassable*: a user cannot forge an org policy without the org's private
7//! key, and cannot weaken a valid one because the runtime folds it in as a floor
8//! ([`crate::core::policy::floor`]).
9//!
10//! Two sources, checked in order:
11//! 1. `LEANCTX_ORG_TRUST_KEY` — one or more comma-separated hex public keys
12//!    (managed by MDM / config-management, never written to disk by us);
13//! 2. the pinned set in `<config_dir>/org-trust.toml`.
14//!
15//! Trust is the *separate* question from signature validity: [`super::OrgPolicyV1::verify`]
16//! proves the bytes were signed by the embedded key; [`is_trusted`] proves that
17//! key is one we accept. Both must hold before a policy is applied.
18
19use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23/// Env override carrying one or more trusted org public keys (hex,
24/// comma-separated). Intended for MDM / fleet provisioning.
25const TRUST_ENV: &str = "LEANCTX_ORG_TRUST_KEY";
26
27/// One pinned org key.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TrustedKey {
30    /// Organisation this key signs for (informational / for `--org` selection).
31    pub org: String,
32    /// Ed25519 public key, hex (64 chars).
33    pub public_key: String,
34    /// When it was pinned (RFC 3339) — for the audit conversation, not enforcement.
35    pub added_at: String,
36}
37
38/// The pinned trust set, persisted as `org-trust.toml`.
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct TrustStore {
41    #[serde(default, rename = "trusted_key", skip_serializing_if = "Vec::is_empty")]
42    pub trusted_keys: Vec<TrustedKey>,
43}
44
45/// Location of the pinned trust file (`<config_dir>/org-trust.toml`).
46pub fn trust_path() -> Result<PathBuf, String> {
47    Ok(crate::core::paths::config_dir()?.join("org-trust.toml"))
48}
49
50/// Load the pinned set. A missing file is the common (un-pinned) case and
51/// yields an empty store, never an error.
52pub fn load() -> Result<TrustStore, String> {
53    let path = trust_path()?;
54    if !path.exists() {
55        return Ok(TrustStore::default());
56    }
57    let text =
58        std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
59    toml::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
60}
61
62/// Persist the pinned set (creating the config dir if needed).
63pub fn save(store: &TrustStore) -> Result<(), String> {
64    let path = trust_path()?;
65    if let Some(parent) = path.parent() {
66        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir config: {e}"))?;
67    }
68    let text = toml::to_string_pretty(store).map_err(|e| format!("serialize trust store: {e}"))?;
69    std::fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))
70}
71
72/// Pin (or re-point) a trusted key for `org`. Re-pinning the same hex key for an
73/// org updates its `added_at`; a different org/key pair is added. Returns
74/// `true` when the set changed.
75pub fn pin(org: &str, public_key: &str) -> Result<bool, String> {
76    let public_key = normalize_key(public_key)?;
77    let mut store = load()?;
78    if let Some(existing) = store
79        .trusted_keys
80        .iter_mut()
81        .find(|k| k.public_key == public_key)
82    {
83        let changed = existing.org != org;
84        existing.org = org.to_string();
85        existing.added_at = now();
86        save(&store)?;
87        return Ok(changed);
88    }
89    store.trusted_keys.push(TrustedKey {
90        org: org.to_string(),
91        public_key,
92        added_at: now(),
93    });
94    save(&store)?;
95    Ok(true)
96}
97
98/// Remove a pinned key by its hex value. Returns `true` when one was removed.
99pub fn remove(public_key: &str) -> Result<bool, String> {
100    let public_key = normalize_key(public_key)?;
101    let mut store = load()?;
102    let before = store.trusted_keys.len();
103    store.trusted_keys.retain(|k| k.public_key != public_key);
104    let removed = store.trusted_keys.len() != before;
105    if removed {
106        save(&store)?;
107    }
108    Ok(removed)
109}
110
111/// All trusted keys (env override first, then the pinned file). Env keys carry
112/// the synthetic org name `env` so `status` can show their provenance.
113pub fn trusted_keys() -> Vec<TrustedKey> {
114    let mut keys: Vec<TrustedKey> = env_keys()
115        .into_iter()
116        .map(|public_key| TrustedKey {
117            org: "env".to_string(),
118            public_key,
119            added_at: String::new(),
120        })
121        .collect();
122    if let Ok(store) = load() {
123        for k in store.trusted_keys {
124            if !keys.iter().any(|e| e.public_key == k.public_key) {
125                keys.push(k);
126            }
127        }
128    }
129    keys
130}
131
132/// Whether `public_key` (hex) is pinned — via the env override or the file.
133#[must_use]
134pub fn is_trusted(public_key: &str) -> bool {
135    let Ok(key) = normalize_key(public_key) else {
136        return false;
137    };
138    env_keys().contains(&key)
139        || load().is_ok_and(|s| s.trusted_keys.iter().any(|k| k.public_key == key))
140}
141
142/// Whether any trust anchor is configured at all (env or file). When `false`,
143/// org distribution is simply not in use on this endpoint (opt-in).
144#[must_use]
145pub fn any_pinned() -> bool {
146    !env_keys().is_empty() || load().is_ok_and(|s| !s.trusted_keys.is_empty())
147}
148
149fn env_keys() -> Vec<String> {
150    std::env::var(TRUST_ENV)
151        .ok()
152        .into_iter()
153        .flat_map(|v| {
154            v.split(',')
155                .filter_map(|k| normalize_key(k).ok())
156                .collect::<Vec<_>>()
157        })
158        .collect()
159}
160
161/// Lower-case + trim a hex key and check it is a 32-byte (64-hex-char) Ed25519
162/// public key. Rejecting malformed input here keeps comparisons exact.
163fn normalize_key(key: &str) -> Result<String, String> {
164    let k = key.trim().to_ascii_lowercase();
165    if k.len() != 64 || !k.bytes().all(|b| b.is_ascii_hexdigit()) {
166        return Err(format!(
167            "invalid public key (expected 64 hex chars, got {})",
168            k.len()
169        ));
170    }
171    Ok(k)
172}
173
174fn now() -> String {
175    chrono::Utc::now().to_rfc3339()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::core::data_dir::isolated_data_dir;
182
183    fn sample_key() -> String {
184        "ab".repeat(32)
185    }
186
187    #[test]
188    fn normalize_rejects_bad_keys() {
189        assert!(normalize_key("xyz").is_err());
190        assert!(normalize_key(&"zz".repeat(32)).is_err());
191        assert!(normalize_key(&sample_key()).is_ok());
192    }
193
194    #[test]
195    fn pin_then_trusted_then_remove() {
196        let _iso = isolated_data_dir();
197        let key = sample_key();
198        assert!(!is_trusted(&key));
199        assert!(pin("acme", &key).unwrap());
200        assert!(is_trusted(&key));
201        assert!(any_pinned());
202        assert!(remove(&key).unwrap());
203        assert!(!is_trusted(&key));
204    }
205
206    #[test]
207    fn repin_same_key_updates_org() {
208        let _iso = isolated_data_dir();
209        let key = sample_key();
210        pin("acme", &key).unwrap();
211        // same key, new org → reported as changed; still a single entry.
212        assert!(pin("acme-renamed", &key).unwrap());
213        assert_eq!(
214            trusted_keys()
215                .iter()
216                .filter(|k| k.public_key == key)
217                .count(),
218            1
219        );
220    }
221
222    #[test]
223    fn env_override_is_trusted() {
224        let _iso = isolated_data_dir();
225        let key = sample_key();
226        crate::test_env::set_var(TRUST_ENV, &key);
227        assert!(is_trusted(&key));
228        assert!(any_pinned());
229        crate::test_env::remove_var(TRUST_ENV);
230    }
231}