Skip to main content

greentic_operator_trust/
trust_root.rs

1//! Trust-root document semantics (`greentic.trust-root.v1`).
2//!
3//! The pure half of per-environment trust-root handling: the on-disk/on-wire
4//! document envelope plus the validate/add/remove transforms. Persistence is
5//! deliberately NOT here — the deployer's `LocalFsStore` keeps
6//! `<env_dir>/trust-root.json` (atomic writes + backups) and the
7//! operator-store-server keeps SQLite rows, but both drive these SAME
8//! functions so key-id canonicalization and validation cannot drift between
9//! backends.
10//!
11//! [`validate_trusted_key`] rejects empty key ids, public keys that do not
12//! parse as Ed25519 SPKI PEM, and a supplied `key_id` that does not match the
13//! canonical derivation in
14//! [`greentic_distributor_client::signing::key_id_for_public_key_pem`] — at
15//! write time, where the failure is actionable, rather than at verify time
16//! where it looks like a missing key. The returned entry carries the
17//! canonical (lowercase) id, so stores never persist a caller-cased one.
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22// Re-exported so consumers (the deployer, the operator-store-server) build
23// trust-root entries through this crate without a direct
24// `greentic-distributor-client` dependency — the canonical types and the
25// single key-id derivation stay in one place.
26pub use greentic_distributor_client::signing::{
27    SigningError, TrustRoot, TrustedKey, key_id_for_public_key_pem,
28};
29
30/// Schema discriminator for the trust-root document.
31pub const TRUST_ROOT_SCHEMA_V1: &str = "greentic.trust-root.v1";
32
33/// Validation and schema errors for trust-root documents. I/O errors stay
34/// with the persisting caller — this crate never touches storage.
35#[derive(Debug, Error)]
36pub enum TrustRootDocError {
37    #[error("trust-root schema `{found}` is not the expected `{TRUST_ROOT_SCHEMA_V1}`")]
38    BadSchema { found: String },
39    #[error("trust-root key validation: {0}")]
40    Key(#[from] SigningError),
41    #[error(
42        "trust-root key_id `{supplied}` does not match the derivation from the public key (`{derived}`)"
43    )]
44    KeyIdMismatch { supplied: String, derived: String },
45    #[error("trust-root key_id `{0}` must be a non-empty hex string")]
46    EmptyKeyId(String),
47}
48
49/// Document envelope wrapping the distributor-client trust-root. Identical
50/// shape whether serialized to `trust-root.json` or a database column.
51#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
52pub struct TrustRootDocument {
53    pub schema: String,
54    pub keys: Vec<TrustedKey>,
55}
56
57impl TrustRootDocument {
58    /// Wrap `keys` in a v1 envelope.
59    pub fn v1(keys: Vec<TrustedKey>) -> Self {
60        Self {
61            schema: TRUST_ROOT_SCHEMA_V1.to_string(),
62            keys,
63        }
64    }
65
66    /// Unwrap into a verifier-ready [`TrustRoot`], rejecting unknown schemas.
67    pub fn into_trust_root(self) -> Result<TrustRoot, TrustRootDocError> {
68        if self.schema != TRUST_ROOT_SCHEMA_V1 {
69            return Err(TrustRootDocError::BadSchema { found: self.schema });
70        }
71        Ok(TrustRoot::new(self.keys))
72    }
73}
74
75/// Validate a caller-supplied `(key_id, public_key_pem)` entry and return it
76/// with the canonical (lowercase) key id. See the module docs for what is
77/// rejected and why validation lives at write time.
78pub fn validate_trusted_key(key: TrustedKey) -> Result<TrustedKey, TrustRootDocError> {
79    if key.key_id.trim().is_empty() {
80        return Err(TrustRootDocError::EmptyKeyId(key.key_id));
81    }
82    let derived = key_id_for_public_key_pem(&key.public_key_pem)?;
83    if !key.key_id.eq_ignore_ascii_case(&derived) {
84        return Err(TrustRootDocError::KeyIdMismatch {
85            supplied: key.key_id,
86            derived,
87        });
88    }
89    Ok(TrustedKey {
90        key_id: derived,
91        public_key_pem: key.public_key_pem,
92    })
93}
94
95/// Add or overwrite a trusted key, deduplicating on case-insensitive
96/// `key_id`. `key` must already be canonical — call [`validate_trusted_key`]
97/// first.
98pub fn apply_add(keys: &mut Vec<TrustedKey>, key: TrustedKey) {
99    keys.retain(|k| !k.key_id.eq_ignore_ascii_case(&key.key_id));
100    keys.push(key);
101}
102
103/// Remove a trusted key by case-insensitive `key_id`. Returns `true` when an
104/// entry was removed — callers use this to skip a persist on the silent
105/// no-op (so a `remove` on a fresh env does not materialize an empty
106/// document where none existed).
107pub fn apply_remove(keys: &mut Vec<TrustedKey>, key_id: &str) -> bool {
108    let before = keys.len();
109    keys.retain(|k| !k.key_id.eq_ignore_ascii_case(key_id));
110    keys.len() != before
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::test_support::keypair;
117
118    #[test]
119    fn validate_accepts_canonical_entry() {
120        let (pem, id) = keypair(1);
121        let key = validate_trusted_key(TrustedKey {
122            key_id: id.clone(),
123            public_key_pem: pem,
124        })
125        .unwrap();
126        assert_eq!(key.key_id, id);
127    }
128
129    #[test]
130    fn validate_canonicalizes_uppercase_key_id() {
131        let (pem, id) = keypair(2);
132        let key = validate_trusted_key(TrustedKey {
133            key_id: id.to_uppercase(),
134            public_key_pem: pem,
135        })
136        .unwrap();
137        assert_eq!(key.key_id, id, "stored id must be canonical lowercase");
138    }
139
140    #[test]
141    fn validate_rejects_mismatched_key_id() {
142        let (pem_a, _id_a) = keypair(3);
143        let (_pem_b, id_b) = keypair(4);
144        let err = validate_trusted_key(TrustedKey {
145            key_id: id_b,
146            public_key_pem: pem_a,
147        })
148        .expect_err("mismatched id must be rejected");
149        assert!(matches!(err, TrustRootDocError::KeyIdMismatch { .. }));
150    }
151
152    #[test]
153    fn validate_rejects_empty_and_whitespace_key_id() {
154        let (pem, _) = keypair(5);
155        let err = validate_trusted_key(TrustedKey {
156            key_id: "   ".into(),
157            public_key_pem: pem,
158        })
159        .expect_err("empty id must be rejected");
160        assert!(matches!(err, TrustRootDocError::EmptyKeyId(_)));
161    }
162
163    #[test]
164    fn validate_rejects_malformed_pem() {
165        let err = validate_trusted_key(TrustedKey {
166            key_id: "abcdef0123456789abcdef0123456789".into(),
167            public_key_pem: "not-a-pem".into(),
168        })
169        .expect_err("bad pem must be rejected");
170        assert!(matches!(err, TrustRootDocError::Key(_)));
171    }
172
173    #[test]
174    fn apply_add_replaces_same_key_id_case_insensitively() {
175        let (pem, id) = keypair(6);
176        let mut keys = vec![TrustedKey {
177            key_id: id.to_uppercase(),
178            public_key_pem: "old".into(),
179        }];
180        apply_add(
181            &mut keys,
182            TrustedKey {
183                key_id: id.clone(),
184                public_key_pem: pem.clone(),
185            },
186        );
187        assert_eq!(keys.len(), 1, "duplicate key_id must dedup");
188        assert_eq!(keys[0].public_key_pem, pem, "PEM must be replaced");
189    }
190
191    #[test]
192    fn apply_add_keeps_distinct_keys() {
193        let (pem_a, id_a) = keypair(7);
194        let (pem_b, id_b) = keypair(8);
195        let mut keys = Vec::new();
196        apply_add(
197            &mut keys,
198            TrustedKey {
199                key_id: id_a,
200                public_key_pem: pem_a,
201            },
202        );
203        apply_add(
204            &mut keys,
205            TrustedKey {
206                key_id: id_b,
207                public_key_pem: pem_b,
208            },
209        );
210        assert_eq!(keys.len(), 2);
211    }
212
213    #[test]
214    fn apply_remove_drops_only_matching_key_and_reports_change() {
215        let (pem_a, id_a) = keypair(9);
216        let (pem_b, id_b) = keypair(10);
217        let mut keys = vec![
218            TrustedKey {
219                key_id: id_a.clone(),
220                public_key_pem: pem_a,
221            },
222            TrustedKey {
223                key_id: id_b.clone(),
224                public_key_pem: pem_b,
225            },
226        ];
227        assert!(apply_remove(&mut keys, &id_a.to_uppercase()));
228        assert_eq!(keys.len(), 1);
229        assert_eq!(keys[0].key_id, id_b);
230        assert!(
231            !apply_remove(&mut keys, &id_a),
232            "absent key must report no change"
233        );
234    }
235
236    #[test]
237    fn document_round_trips_and_rejects_unknown_schema() {
238        let (pem, id) = keypair(11);
239        let doc = TrustRootDocument::v1(vec![TrustedKey {
240            key_id: id.clone(),
241            public_key_pem: pem,
242        }]);
243        let json = serde_json::to_string(&doc).unwrap();
244        let parsed: TrustRootDocument = serde_json::from_str(&json).unwrap();
245        let root = parsed.into_trust_root().unwrap();
246        assert_eq!(root.keys.len(), 1);
247        assert_eq!(root.keys[0].key_id, id);
248
249        let bad: TrustRootDocument =
250            serde_json::from_str(r#"{"schema":"greentic.trust-root.v999","keys":[]}"#).unwrap();
251        let err = bad.into_trust_root().expect_err("bad schema must reject");
252        assert!(matches!(err, TrustRootDocError::BadSchema { .. }));
253    }
254}