Skip to main content

auths_core/trust/
roots_file.rs

1//! Roots file loader for CI explicit trust.
2//!
3//! This module provides loading and validation for `.auths/roots.json` files,
4//! which allow repositories to define trusted identity roots for CI pipelines.
5
6use auths_verifier::PublicKeyHex;
7use serde::Deserialize;
8use std::path::Path;
9
10use crate::error::TrustError;
11
12/// A roots.json file containing trusted identity roots.
13///
14/// This file is checked into repositories at `.auths/roots.json` to define
15/// which identities are trusted for verification in CI environments.
16///
17/// # Format
18///
19/// ```json
20/// {
21///   "version": 1,
22///   "roots": [
23///     {
24///       "did": "did:keri:EXq5YqaL...",
25///       "public_key_hex": "7a3bc2...",
26///       "kel_tip_said": "ERotSaid...",
27///       "note": "Primary maintainer"
28///     }
29///   ]
30/// }
31/// ```
32#[derive(Debug, Deserialize)]
33pub struct RootsFile {
34    /// Version of the roots file format. Currently must be 1.
35    pub version: u32,
36
37    /// List of trusted identity roots.
38    pub roots: Vec<RootEntry>,
39}
40
41/// A single trusted identity root entry.
42#[derive(Debug, Deserialize)]
43pub struct RootEntry {
44    /// The DID of the trusted identity (e.g., "did:keri:EXq5...")
45    pub did: String,
46
47    /// The public key in hex format (32 bytes Ed25519 or 33 bytes P-256 compressed).
48    pub public_key_hex: PublicKeyHex,
49
50    /// Curve of the root's public key (fn-114.35). Required — pre-launch hard
51    /// break, no v1 fallback.
52    pub curve: auths_crypto::CurveType,
53
54    /// Optional KEL tip SAID for rotation-aware matching.
55    #[serde(default)]
56    pub kel_tip_said: Option<String>,
57
58    /// Optional human-readable note about this root.
59    #[serde(default)]
60    pub note: Option<String>,
61}
62
63impl RootsFile {
64    /// Parse roots from a JSON string (pure — no I/O).
65    ///
66    /// Prefer this over `load` when the caller already has the content.
67    pub fn parse(content: &str) -> Result<Self, TrustError> {
68        let file: Self = serde_json::from_str(content)?;
69
70        if file.version != 2 {
71            return Err(TrustError::InvalidData(format!(
72                "Unsupported roots.json version: {}. Expected version 2 (fn-114.35 hard break).",
73                file.version
74            )));
75        }
76
77        Ok(file)
78    }
79
80    /// Load and validate a roots.json file.
81    #[allow(clippy::disallowed_methods)] // INVARIANT: convenience wrapper; prefer parse() for sans-IO callers
82    pub fn load(path: &Path) -> Result<Self, TrustError> {
83        let content = std::fs::read_to_string(path)?;
84        Self::parse(&content)
85    }
86
87    /// Find a root entry by DID.
88    pub fn find(&self, did: &str) -> Option<&RootEntry> {
89        self.roots.iter().find(|r| r.did == did)
90    }
91
92    /// Get all DIDs in this roots file.
93    pub fn dids(&self) -> Vec<&str> {
94        self.roots.iter().map(|r| r.did.as_str()).collect()
95    }
96}
97
98impl RootEntry {
99    /// Decode the public key to raw bytes.
100    pub fn public_key_bytes(&self) -> Result<Vec<u8>, TrustError> {
101        hex::decode(self.public_key_hex.as_str())
102            .map_err(|e| TrustError::InvalidData(format!("Invalid public_key_hex: {}", e)))
103    }
104}
105
106#[cfg(test)]
107#[allow(clippy::disallowed_methods)]
108#[allow(clippy::disallowed_types)]
109mod tests {
110    use super::*;
111    use std::io::Write;
112
113    fn create_temp_roots_file(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
114        let dir = tempfile::tempdir().unwrap();
115        let path = dir.path().join("roots.json");
116        let mut file = std::fs::File::create(&path).unwrap();
117        file.write_all(content.as_bytes()).unwrap();
118        (dir, path)
119    }
120
121    #[test]
122    fn test_load_valid_roots_file() {
123        let content = r#"{
124            "version": 2,
125            "roots": [
126                {
127                    "did": "did:keri:ETest123",
128                    "public_key_hex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
129                    "curve": "ed25519",
130                    "kel_tip_said": "ETip",
131                    "note": "Test maintainer"
132                }
133            ]
134        }"#;
135
136        let (_dir, path) = create_temp_roots_file(content);
137        let roots = RootsFile::load(&path).unwrap();
138
139        assert_eq!(roots.version, 2);
140        assert_eq!(roots.roots.len(), 1);
141        assert_eq!(roots.roots[0].did, "did:keri:ETest123");
142        assert_eq!(roots.roots[0].kel_tip_said, Some("ETip".to_string()));
143        assert_eq!(roots.roots[0].note, Some("Test maintainer".to_string()));
144    }
145
146    #[test]
147    fn test_load_minimal_entry() {
148        let content = r#"{
149            "version": 2,
150            "roots": [
151                {
152                    "did": "did:keri:ETest",
153                    "public_key_hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
154                    "curve": "ed25519"
155                }
156            ]
157        }"#;
158
159        let (_dir, path) = create_temp_roots_file(content);
160        let roots = RootsFile::load(&path).unwrap();
161
162        assert_eq!(roots.roots[0].kel_tip_said, None);
163        assert_eq!(roots.roots[0].note, None);
164    }
165
166    #[test]
167    fn test_load_rejects_wrong_version() {
168        // version 2 is the only supported; v1 or any other must reject.
169        let content = r#"{
170            "version": 1,
171            "roots": []
172        }"#;
173
174        let (_dir, path) = create_temp_roots_file(content);
175        let result = RootsFile::load(&path);
176
177        assert!(result.is_err());
178        assert!(result.unwrap_err().to_string().contains("version"));
179    }
180
181    #[test]
182    fn test_load_rejects_invalid_hex() {
183        let content = r#"{
184            "version": 2,
185            "roots": [
186                {
187                    "did": "did:keri:ETest",
188                    "public_key_hex": "not-valid-hex",
189                    "curve": "ed25519"
190                }
191            ]
192        }"#;
193
194        let (_dir, path) = create_temp_roots_file(content);
195        let result = RootsFile::load(&path);
196
197        assert!(result.is_err());
198    }
199
200    #[test]
201    fn test_load_rejects_wrong_key_length() {
202        let content = r#"{
203            "version": 2,
204            "roots": [
205                {
206                    "did": "did:keri:ETest",
207                    "public_key_hex": "0102030405",
208                    "curve": "ed25519"
209                }
210            ]
211        }"#;
212
213        let (_dir, path) = create_temp_roots_file(content);
214        let result = RootsFile::load(&path);
215
216        assert!(result.is_err());
217    }
218
219    #[test]
220    fn test_find_by_did() {
221        let content = r#"{
222            "version": 2,
223            "roots": [
224                {
225                    "did": "did:keri:E111",
226                    "public_key_hex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
227                    "curve": "ed25519"
228                },
229                {
230                    "did": "did:keri:E222",
231                    "public_key_hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
232                    "curve": "ed25519"
233                }
234            ]
235        }"#;
236
237        let (_dir, path) = create_temp_roots_file(content);
238        let roots = RootsFile::load(&path).unwrap();
239
240        assert!(roots.find("did:keri:E111").is_some());
241        assert!(roots.find("did:keri:E222").is_some());
242        assert!(roots.find("did:keri:E333").is_none());
243    }
244
245    #[test]
246    fn test_dids() {
247        let content = r#"{
248            "version": 2,
249            "roots": [
250                {
251                    "did": "did:keri:E111",
252                    "public_key_hex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
253                    "curve": "ed25519"
254                },
255                {
256                    "did": "did:keri:E222",
257                    "public_key_hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
258                    "curve": "ed25519"
259                }
260            ]
261        }"#;
262
263        let (_dir, path) = create_temp_roots_file(content);
264        let roots = RootsFile::load(&path).unwrap();
265        let dids = roots.dids();
266
267        assert_eq!(dids.len(), 2);
268        assert!(dids.contains(&"did:keri:E111"));
269        assert!(dids.contains(&"did:keri:E222"));
270    }
271
272    #[test]
273    fn test_root_entry_public_key_bytes() {
274        let entry = RootEntry {
275            did: "did:keri:ETest".to_string(),
276            public_key_hex: PublicKeyHex::new_unchecked(
277                "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
278            ),
279            curve: auths_crypto::CurveType::Ed25519,
280            kel_tip_said: None,
281            note: None,
282        };
283
284        let bytes = entry.public_key_bytes().unwrap();
285        assert_eq!(bytes.len(), 32);
286        assert_eq!(bytes[0], 0x01);
287    }
288}