Skip to main content

greentic_deployer/environment/
trust_root.rs

1//! Per-environment trust-root persistence (C2 of `plans/next-gen-deployment.md`).
2//!
3//! Owns `<env_dir>/trust-root.json`, the **closed-by-default** allowlist of
4//! Ed25519 public keys the operator trusts to sign artifacts whose digests
5//! are pinned in revisions or revenue-policy documents under this env.
6//!
7//! The on-disk schema (`greentic.trust-root.v1`) is a thin envelope around
8//! [`greentic_distributor_client::signing::TrustedKey`]; [`load`] returns a
9//! ready-to-use [`greentic_distributor_client::signing::TrustRoot`] so
10//! verifiers don't translate between two shapes. A missing file yields an
11//! empty `TrustRoot` — verification then fails closed because no key matches.
12//!
13//! Document semantics (schema envelope, key validation, add/remove
14//! transforms) live in [`greentic_operator_trust::trust_root`] so the
15//! operator-store-server's SQLite-backed trust roots drive the SAME
16//! functions (PR-4.2e); this module owns only the file persistence —
17//! atomic writes plus the `backups/` copy of each predecessor.
18
19use std::path::{Path, PathBuf};
20
21use greentic_distributor_client::signing::{TrustRoot, TrustedKey};
22pub use greentic_operator_trust::trust_root::{
23    TRUST_ROOT_SCHEMA_V1, TrustRootDocError, TrustRootDocument,
24};
25use greentic_operator_trust::trust_root::{apply_add, apply_remove, validate_trusted_key};
26use thiserror::Error;
27
28use super::atomic_write::{AtomicWriteError, atomic_write_json, copy_to_backup};
29
30/// Env-relative sub-directory under which previous `trust-root.json`
31/// revisions are copied before each save (Codex #3 recovery hook).
32const TRUST_ROOT_BACKUP_DIR: &str = "backups";
33
34/// Filename under `<env_dir>` holding the trust-root document.
35pub const TRUST_ROOT_FILE: &str = "trust-root.json";
36
37#[derive(Debug, Error)]
38pub enum TrustRootError {
39    #[error("trust-root io on {path}: {source}")]
40    Io {
41        path: PathBuf,
42        #[source]
43        source: std::io::Error,
44    },
45    #[error("trust-root write {path}: {source}")]
46    Write {
47        path: PathBuf,
48        #[source]
49        source: AtomicWriteError,
50    },
51    #[error("trust-root parse {path}: {source}")]
52    Parse {
53        path: PathBuf,
54        #[source]
55        source: serde_json::Error,
56    },
57    /// Document-level validation/schema failure — see
58    /// [`greentic_operator_trust::trust_root::TrustRootDocError`].
59    /// Transparent so user-facing messages are unchanged from when these
60    /// variants lived inline here.
61    #[error(transparent)]
62    Doc(#[from] TrustRootDocError),
63}
64
65/// Absolute path to `<env_dir>/trust-root.json` (the file is not required to
66/// exist).
67pub fn trust_root_path(env_dir: &Path) -> PathBuf {
68    env_dir.join(TRUST_ROOT_FILE)
69}
70
71/// Load `<env_dir>/trust-root.json` into a verifier-ready [`TrustRoot`].
72///
73/// A missing file returns an **empty** trust root (`is_empty() == true`) —
74/// the verifier then rejects every signature, which is the intended
75/// closed-by-default behavior.
76pub fn load(env_dir: &Path) -> Result<TrustRoot, TrustRootError> {
77    let path = trust_root_path(env_dir);
78    let bytes = match std::fs::read(&path) {
79        Ok(b) => b,
80        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81            return Ok(TrustRoot::default());
82        }
83        Err(source) => return Err(TrustRootError::Io { path, source }),
84    };
85    let doc: TrustRootDocument =
86        serde_json::from_slice(&bytes).map_err(|source| TrustRootError::Parse {
87            path: path.clone(),
88            source,
89        })?;
90    Ok(doc.into_trust_root()?)
91}
92
93/// Add or overwrite a trusted key. Validates that `public_key_pem` parses as
94/// an Ed25519 SPKI PEM and that `key_id` matches the canonical derivation.
95///
96/// Idempotent on case-insensitive `key_id` match: the existing entry's PEM
97/// is replaced. Returns the resulting trust root.
98///
99/// **Concurrency contract.** This function does not acquire the env flock
100/// itself — callers MUST wrap the call in
101/// [`crate::environment::EnvironmentStore::transact`] so two concurrent
102/// `add`/`remove` invocations don't read-modify-write past each other.
103/// Production callers (`gtc op trust-root *` verbs in
104/// [`crate::cli::trust_root`]) honor this; test fixtures that own the
105/// tempdir exclusively are the only sanctioned exception.
106pub fn add_trusted_key(env_dir: &Path, key: TrustedKey) -> Result<TrustRoot, TrustRootError> {
107    let key = validate_trusted_key(key)?;
108    let mut current = load_keys(env_dir)?;
109    apply_add(&mut current, key);
110    save(env_dir, &current)?;
111    Ok(TrustRoot::new(current))
112}
113
114/// Remove a trusted key by case-insensitive `key_id`. A missing key is a
115/// silent no-op (the trust root is already in the requested state). The
116/// no-op case skips `save()` entirely — so a `remove` on a fresh env (no
117/// `trust-root.json` yet) does NOT create an empty file where none existed.
118///
119/// **Concurrency contract.** Same as [`add_trusted_key`] — callers MUST
120/// hold the env flock via
121/// [`crate::environment::EnvironmentStore::transact`].
122pub fn remove_trusted_key(env_dir: &Path, key_id: &str) -> Result<TrustRoot, TrustRootError> {
123    let mut current = load_keys(env_dir)?;
124    if apply_remove(&mut current, key_id) {
125        save(env_dir, &current)?;
126    }
127    Ok(TrustRoot::new(current))
128}
129
130fn load_keys(env_dir: &Path) -> Result<Vec<TrustedKey>, TrustRootError> {
131    let root = load(env_dir)?;
132    Ok(root.keys)
133}
134
135/// Atomically replace `<env_dir>/trust-root.json` with `keys`, first
136/// copying the previous file (if any) into `<env_dir>/backups/` with an
137/// RFC-3339 timestamp.
138///
139/// Codex #3: an accidental or malicious `add`/`remove` would otherwise be
140/// unrecoverable — the CLI audit log records only `key_id`, not enough to
141/// reconstruct removed PEMs. The backup keeps the prior trust root one
142/// directory away, and the CLI emits the full `(key_id, public_pem)` pair
143/// in its audit `target`.
144fn save(env_dir: &Path, keys: &[TrustedKey]) -> Result<(), TrustRootError> {
145    let path = trust_root_path(env_dir);
146    copy_to_backup(&path, &env_dir.join(TRUST_ROOT_BACKUP_DIR)).map_err(|source| {
147        TrustRootError::Write {
148            path: path.clone(),
149            source,
150        }
151    })?;
152    let doc = TrustRootDocument::v1(keys.to_vec());
153    atomic_write_json(&path, &doc).map_err(|source| TrustRootError::Write { path, source })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use greentic_operator_trust::test_support::keypair;
160    use tempfile::tempdir;
161
162    #[test]
163    fn load_missing_file_returns_empty_trust_root() {
164        let dir = tempdir().unwrap();
165        let tr = load(dir.path()).unwrap();
166        assert!(tr.is_empty());
167    }
168
169    #[test]
170    fn add_then_load_roundtrips_a_key() {
171        let dir = tempdir().unwrap();
172        let (pem, key_id) = keypair(1);
173        let tr = add_trusted_key(
174            dir.path(),
175            TrustedKey {
176                key_id: key_id.clone(),
177                public_key_pem: pem.clone(),
178            },
179        )
180        .unwrap();
181        assert_eq!(tr.keys.len(), 1);
182        assert_eq!(tr.keys[0].key_id, key_id);
183
184        let reloaded = load(dir.path()).unwrap();
185        assert_eq!(reloaded.keys, tr.keys);
186    }
187
188    #[test]
189    fn add_with_uppercase_key_id_normalizes_to_canonical_lowercase() {
190        let dir = tempdir().unwrap();
191        let (pem, key_id) = keypair(2);
192        let uppercase = key_id.to_uppercase();
193        let tr = add_trusted_key(
194            dir.path(),
195            TrustedKey {
196                key_id: uppercase,
197                public_key_pem: pem,
198            },
199        )
200        .unwrap();
201        assert_eq!(tr.keys[0].key_id, key_id, "stored id must be canonical");
202    }
203
204    #[test]
205    fn add_with_mismatched_key_id_is_rejected() {
206        let dir = tempdir().unwrap();
207        let (pem_a, _id_a) = keypair(3);
208        let (_pem_b, id_b) = keypair(4);
209        let err = add_trusted_key(
210            dir.path(),
211            TrustedKey {
212                key_id: id_b,
213                public_key_pem: pem_a,
214            },
215        )
216        .expect_err("mismatched id must be rejected");
217        assert!(matches!(
218            err,
219            TrustRootError::Doc(TrustRootDocError::KeyIdMismatch { .. })
220        ));
221        // File was never written.
222        assert!(!trust_root_path(dir.path()).exists());
223    }
224
225    #[test]
226    fn add_with_empty_key_id_is_rejected() {
227        let dir = tempdir().unwrap();
228        let (pem, _) = keypair(5);
229        let err = add_trusted_key(
230            dir.path(),
231            TrustedKey {
232                key_id: "   ".into(),
233                public_key_pem: pem,
234            },
235        )
236        .expect_err("empty id must be rejected");
237        assert!(matches!(
238            err,
239            TrustRootError::Doc(TrustRootDocError::EmptyKeyId(_))
240        ));
241    }
242
243    #[test]
244    fn add_with_malformed_pem_is_rejected_pre_write() {
245        let dir = tempdir().unwrap();
246        let err = add_trusted_key(
247            dir.path(),
248            TrustedKey {
249                key_id: "abcdef".repeat(5).chars().take(32).collect(),
250                public_key_pem: "not-a-pem".into(),
251            },
252        )
253        .expect_err("bad pem must be rejected");
254        assert!(matches!(
255            err,
256            TrustRootError::Doc(TrustRootDocError::Key(_))
257        ));
258        assert!(!trust_root_path(dir.path()).exists());
259    }
260
261    #[test]
262    fn add_replaces_existing_key_with_same_key_id() {
263        let dir = tempdir().unwrap();
264        let (pem, id) = keypair(6);
265        add_trusted_key(
266            dir.path(),
267            TrustedKey {
268                key_id: id.clone(),
269                public_key_pem: pem.clone(),
270            },
271        )
272        .unwrap();
273        let tr = add_trusted_key(
274            dir.path(),
275            TrustedKey {
276                key_id: id.to_uppercase(),
277                public_key_pem: pem,
278            },
279        )
280        .unwrap();
281        assert_eq!(tr.keys.len(), 1, "duplicate key_id must dedup");
282    }
283
284    #[test]
285    fn add_two_distinct_keys_yields_two_entries() {
286        let dir = tempdir().unwrap();
287        let (pem_a, id_a) = keypair(7);
288        let (pem_b, id_b) = keypair(8);
289        add_trusted_key(
290            dir.path(),
291            TrustedKey {
292                key_id: id_a,
293                public_key_pem: pem_a,
294            },
295        )
296        .unwrap();
297        let tr = add_trusted_key(
298            dir.path(),
299            TrustedKey {
300                key_id: id_b,
301                public_key_pem: pem_b,
302            },
303        )
304        .unwrap();
305        assert_eq!(tr.keys.len(), 2);
306    }
307
308    #[test]
309    fn remove_drops_only_matching_key() {
310        let dir = tempdir().unwrap();
311        let (pem_a, id_a) = keypair(9);
312        let (pem_b, id_b) = keypair(10);
313        add_trusted_key(
314            dir.path(),
315            TrustedKey {
316                key_id: id_a.clone(),
317                public_key_pem: pem_a,
318            },
319        )
320        .unwrap();
321        add_trusted_key(
322            dir.path(),
323            TrustedKey {
324                key_id: id_b.clone(),
325                public_key_pem: pem_b,
326            },
327        )
328        .unwrap();
329        let tr = remove_trusted_key(dir.path(), &id_a).unwrap();
330        assert_eq!(tr.keys.len(), 1);
331        assert_eq!(tr.keys[0].key_id, id_b);
332    }
333
334    #[test]
335    fn remove_on_fresh_env_does_not_create_trust_root_file() {
336        // xhigh #8: a 'remove' for a non-existent key on a fresh env must
337        // NOT materialize an empty trust-root.json — the absence of the
338        // file is itself meaningful state (bootstrap-detection).
339        let dir = tempdir().unwrap();
340        assert!(!trust_root_path(dir.path()).exists());
341        let tr = remove_trusted_key(dir.path(), "00ff00ff00ff00ff00ff00ff00ff00ff").unwrap();
342        assert!(tr.is_empty());
343        assert!(
344            !trust_root_path(dir.path()).exists(),
345            "no-op remove must not create an empty trust-root.json"
346        );
347    }
348
349    #[test]
350    fn remove_unknown_key_is_a_silent_noop() {
351        let dir = tempdir().unwrap();
352        let (pem, id) = keypair(11);
353        add_trusted_key(
354            dir.path(),
355            TrustedKey {
356                key_id: id.clone(),
357                public_key_pem: pem,
358            },
359        )
360        .unwrap();
361        let tr = remove_trusted_key(dir.path(), "00ff00ff00ff00ff00ff00ff00ff00ff").unwrap();
362        assert_eq!(tr.keys.len(), 1, "non-matching removal is a no-op");
363        assert_eq!(tr.keys[0].key_id, id);
364    }
365
366    #[test]
367    fn add_writes_prior_trust_root_to_backups_dir() {
368        // Codex #3: every save copies the previous file aside so a bad
369        // `add`/`remove` is recoverable from disk.
370        let dir = tempdir().unwrap();
371        let (pem_a, id_a) = keypair(40);
372        let (pem_b, id_b) = keypair(41);
373        add_trusted_key(
374            dir.path(),
375            TrustedKey {
376                key_id: id_a.clone(),
377                public_key_pem: pem_a,
378            },
379        )
380        .unwrap();
381        add_trusted_key(
382            dir.path(),
383            TrustedKey {
384                key_id: id_b,
385                public_key_pem: pem_b,
386            },
387        )
388        .unwrap();
389        // Second save copied the v1 (id_a-only) file to backups/.
390        let backups: Vec<_> = std::fs::read_dir(dir.path().join("backups"))
391            .unwrap()
392            .filter_map(|e| e.ok())
393            .filter(|e| {
394                e.file_name()
395                    .to_string_lossy()
396                    .starts_with("trust-root.json.")
397            })
398            .collect();
399        assert!(
400            !backups.is_empty(),
401            "expected a trust-root backup file under backups/"
402        );
403        let backup_contents = std::fs::read_to_string(backups[0].path()).unwrap();
404        let parsed: TrustRootDocument = serde_json::from_str(&backup_contents).unwrap();
405        assert_eq!(parsed.keys.len(), 1);
406        assert!(parsed.keys[0].key_id.eq_ignore_ascii_case(&id_a));
407    }
408
409    #[test]
410    fn remove_writes_prior_trust_root_to_backups_dir() {
411        let dir = tempdir().unwrap();
412        let (pem, id) = keypair(42);
413        add_trusted_key(
414            dir.path(),
415            TrustedKey {
416                key_id: id.clone(),
417                public_key_pem: pem,
418            },
419        )
420        .unwrap();
421        remove_trusted_key(dir.path(), &id).unwrap();
422        // The `add` saved once (no prior backup), the `remove` saved a
423        // second time copying the post-add file into backups/.
424        let backups: Vec<_> = std::fs::read_dir(dir.path().join("backups"))
425            .unwrap()
426            .filter_map(|e| e.ok())
427            .filter(|e| {
428                e.file_name()
429                    .to_string_lossy()
430                    .starts_with("trust-root.json.")
431            })
432            .collect();
433        assert_eq!(backups.len(), 1, "remove must back up its predecessor");
434        let parsed: TrustRootDocument =
435            serde_json::from_str(&std::fs::read_to_string(backups[0].path()).unwrap()).unwrap();
436        assert_eq!(parsed.keys.len(), 1);
437        assert!(parsed.keys[0].key_id.eq_ignore_ascii_case(&id));
438    }
439
440    #[test]
441    fn unknown_schema_is_rejected_on_load() {
442        let dir = tempdir().unwrap();
443        std::fs::write(
444            trust_root_path(dir.path()),
445            br#"{"schema":"greentic.trust-root.v999","keys":[]}"#,
446        )
447        .unwrap();
448        let err = load(dir.path()).expect_err("bad schema must reject");
449        assert!(matches!(
450            err,
451            TrustRootError::Doc(TrustRootDocError::BadSchema { .. })
452        ));
453    }
454
455    #[test]
456    fn malformed_json_is_rejected_on_load() {
457        let dir = tempdir().unwrap();
458        std::fs::write(trust_root_path(dir.path()), b"{not json}").unwrap();
459        let err = load(dir.path()).expect_err("bad json must reject");
460        assert!(matches!(err, TrustRootError::Parse { .. }));
461    }
462}