Skip to main content

auths_sdk/workflows/
roots.rs

1//! Trusted-root pinning — the `roots` allowlist of root `did:keri:` prefixes a
2//! verifier accepts as delegation anchors.
3//!
4//! This is the successor to `allowed_signers` as the **root** of trust. Under the
5//! delegation model a commit carries its signer's root in the `Auths-Id` trailer,
6//! but that trailer is attacker-controllable: it may only **select** among pinned
7//! roots, never **establish** a new one. TOFS on a device is bounded (the root can
8//! revoke it); TOFS on the root is unbounded — so the trusted root set is pinned
9//! out-of-band in `<auths_dir>/roots`, one `did:keri:` per line (blank lines and
10//! `#` comments ignored).
11
12use std::path::{Path, PathBuf};
13
14use auths_verifier::IdentityDID;
15
16use crate::ports::{ConfigStore, ConfigStoreError};
17
18const ROOTS_FILE: &str = "roots";
19
20/// Path to the pin file within `auths_dir`.
21fn roots_path(auths_dir: &Path) -> PathBuf {
22    auths_dir.join(ROOTS_FILE)
23}
24
25/// Load the pinned trusted-root `did:keri:` set. Empty when the file is absent.
26///
27/// Blank lines and `#`-prefixed comments are ignored; entries are trimmed.
28///
29/// Args:
30/// * `store`: File-access port for the pin file.
31/// * `auths_dir`: Directory holding the `roots` pin file.
32///
33/// Usage:
34/// ```ignore
35/// let roots = load_pinned_roots(&store, &auths_dir)?;
36/// ```
37pub fn load_pinned_roots(
38    store: &dyn ConfigStore,
39    auths_dir: &Path,
40) -> Result<Vec<String>, ConfigStoreError> {
41    match store.read(&roots_path(auths_dir))? {
42        Some(content) => Ok(parse_roots(&content)),
43        None => Ok(Vec::new()),
44    }
45}
46
47/// Parse pin-file content into the trusted-root set (pure; no I/O).
48///
49/// Args:
50/// * `content`: Raw `roots` file contents.
51///
52/// Usage:
53/// ```ignore
54/// let roots = parse_roots("did:keri:Eabc\n# note\n\n");
55/// assert_eq!(roots, vec!["did:keri:Eabc".to_string()]);
56/// ```
57pub fn parse_roots(content: &str) -> Vec<String> {
58    content
59        .lines()
60        .map(str::trim)
61        .filter(|line| !line.is_empty() && !line.starts_with('#'))
62        .map(str::to_string)
63        .collect()
64}
65
66/// Whether `did` (a `did:keri:` string) is a pinned trusted root.
67///
68/// Args:
69/// * `store`: File-access port for the pin file.
70/// * `auths_dir`: Directory holding the `roots` pin file.
71/// * `did`: The candidate root `did:keri:`.
72///
73/// Usage:
74/// ```ignore
75/// if is_pinned_root(&store, &auths_dir, &trailer_root_did)? { /* trust it */ }
76/// ```
77pub fn is_pinned_root(
78    store: &dyn ConfigStore,
79    auths_dir: &Path,
80    did: &str,
81) -> Result<bool, ConfigStoreError> {
82    Ok(load_pinned_roots(store, auths_dir)?
83        .iter()
84        .any(|root| root == did))
85}
86
87/// Pin a trusted root `did:keri:` (idempotent — never duplicates an existing entry).
88///
89/// Creates `auths_dir` if needed (the store's `write` creates parent directories).
90/// Used by `auths init` to seed the local root.
91///
92/// Args:
93/// * `store`: File-access port for the pin file.
94/// * `auths_dir`: Directory holding the `roots` pin file.
95/// * `did`: The root `did:keri:` to pin.
96///
97/// Usage:
98/// ```ignore
99/// add_pinned_root(&store, &auths_dir, &controller_did)?;
100/// ```
101pub fn add_pinned_root(
102    store: &dyn ConfigStore,
103    auths_dir: &Path,
104    did: &str,
105) -> Result<(), ConfigStoreError> {
106    let mut roots = load_pinned_roots(store, auths_dir)?;
107    if roots.iter().any(|root| root == did) {
108        return Ok(());
109    }
110    roots.push(did.to_string());
111    store.write(&roots_path(auths_dir), &format!("{}\n", roots.join("\n")))
112}
113
114/// Failure loading the typed pinned-root set: a line that is not a well-formed
115/// `did:keri:` identity DID is rejected (fail-closed) rather than silently skipped.
116#[derive(Debug, thiserror::Error)]
117pub enum RootsError {
118    /// The `roots` pin file could not be read.
119    #[error("could not read roots pin file: {0}")]
120    Store(#[from] ConfigStoreError),
121
122    /// A non-comment, non-blank line is not a valid `did:keri:` root.
123    #[error("roots pin line {line} ({value:?}) is not a valid did:keri: root: {source}")]
124    MalformedRoot {
125        /// 1-based line number in the pin file.
126        line: usize,
127        /// The offending (trimmed) line content.
128        value: String,
129        /// The underlying DID parse error.
130        #[source]
131        source: auths_verifier::DidParseError,
132    },
133}
134
135/// Parse pin-file content into typed trusted-root identities (pure; no I/O).
136///
137/// Each non-blank, non-`#` line must be a well-formed `did:keri:` identity DID;
138/// a malformed line fails closed with [`RootsError::MalformedRoot`]. The pin file
139/// names **identities only** — capabilities come from the presented credential's
140/// scope seal, never this file.
141///
142/// Args:
143/// * `content`: Raw `roots` file contents.
144///
145/// Usage:
146/// ```ignore
147/// let roots = parse_roots_typed("did:keri:Eabc\n")?;
148/// ```
149pub fn parse_roots_typed(content: &str) -> Result<Vec<IdentityDID>, RootsError> {
150    content
151        .lines()
152        .enumerate()
153        .map(|(idx, raw)| (idx + 1, raw.trim()))
154        .filter(|(_, line)| !line.is_empty() && !line.starts_with('#'))
155        .map(|(line, value)| {
156            IdentityDID::parse(value).map_err(|source| RootsError::MalformedRoot {
157                line,
158                value: value.to_string(),
159                source,
160            })
161        })
162        .collect()
163}
164
165/// Load the pinned trusted-root set as typed [`IdentityDID`]s, failing closed on a
166/// malformed line. Empty when the file is absent.
167///
168/// This is the typed successor to [`load_pinned_roots`]: the relying-party middleware
169/// pins delegation anchors by parsed identity, so a malformed pin is a hard error at
170/// load rather than a silently-dropped root.
171///
172/// Args:
173/// * `store`: File-access port for the pin file.
174/// * `auths_dir`: Directory holding the `roots` pin file.
175///
176/// Usage:
177/// ```ignore
178/// let roots = load_pinned_roots_typed(&store, &auths_dir)?;
179/// ```
180pub fn load_pinned_roots_typed(
181    store: &dyn ConfigStore,
182    auths_dir: &Path,
183) -> Result<Vec<IdentityDID>, RootsError> {
184    match store.read(&roots_path(auths_dir))? {
185        Some(content) => parse_roots_typed(&content),
186        None => Ok(Vec::new()),
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    struct FsStore;
195
196    impl ConfigStore for FsStore {
197        fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
198            match std::fs::read_to_string(path) {
199                Ok(content) => Ok(Some(content)),
200                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
201                Err(e) => Err(ConfigStoreError::Read {
202                    path: path.to_path_buf(),
203                    source: e,
204                }),
205            }
206        }
207
208        fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
209            if let Some(parent) = path.parent() {
210                std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
211                    path: path.to_path_buf(),
212                    source: e,
213                })?;
214            }
215            std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
216                path: path.to_path_buf(),
217                source: e,
218            })
219        }
220    }
221
222    #[test]
223    fn parse_roots_ignores_blanks_and_comments() {
224        let content = "did:keri:Eaaa\n\n# a comment\n  did:keri:Ebbb  \n";
225        assert_eq!(
226            parse_roots(content),
227            vec!["did:keri:Eaaa".to_string(), "did:keri:Ebbb".to_string()]
228        );
229    }
230
231    #[test]
232    fn typed_roots_parse_valid_did_keri() {
233        let roots = parse_roots_typed("did:keri:Eaaa\n# c\n  did:keri:Ebbb \n").expect("parse");
234        assert_eq!(
235            roots.iter().map(|r| r.as_str()).collect::<Vec<_>>(),
236            ["did:keri:Eaaa", "did:keri:Ebbb"]
237        );
238    }
239
240    #[test]
241    fn typed_roots_reject_malformed_line_fail_closed() {
242        // A non-did:keri line is a hard error (with its line number), never skipped.
243        let err = parse_roots_typed("did:keri:Eaaa\nnot-a-did\n").expect_err("must fail closed");
244        assert!(
245            matches!(err, RootsError::MalformedRoot { line: 2, .. }),
246            "expected MalformedRoot at line 2, got {err:?}"
247        );
248    }
249
250    #[test]
251    fn typed_roots_absent_file_is_empty() {
252        let tmp = tempfile::TempDir::new().expect("temp dir");
253        assert!(
254            load_pinned_roots_typed(&FsStore, tmp.path())
255                .expect("load")
256                .is_empty()
257        );
258    }
259
260    #[test]
261    fn add_and_membership_roundtrip() {
262        let tmp = tempfile::TempDir::new().expect("temp dir");
263        let dir = tmp.path();
264        let store = FsStore;
265
266        // Absent file → empty + not pinned.
267        assert!(load_pinned_roots(&store, dir).expect("load").is_empty());
268        assert!(!is_pinned_root(&store, dir, "did:keri:Eroot").expect("check"));
269
270        add_pinned_root(&store, dir, "did:keri:Eroot").expect("add");
271        assert!(is_pinned_root(&store, dir, "did:keri:Eroot").expect("check pinned"));
272        assert!(!is_pinned_root(&store, dir, "did:keri:Eother").expect("check unpinned"));
273
274        // Idempotent.
275        add_pinned_root(&store, dir, "did:keri:Eroot").expect("add again");
276        assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 1);
277
278        // A second distinct root.
279        add_pinned_root(&store, dir, "did:keri:Esecond").expect("add second");
280        assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 2);
281    }
282}