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