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
16const ROOTS_FILE: &str = "roots";
17
18/// Path to the pin file within `auths_dir`.
19fn roots_path(auths_dir: &Path) -> PathBuf {
20    auths_dir.join(ROOTS_FILE)
21}
22
23/// Load the pinned trusted-root `did:keri:` set. Empty when the file is absent.
24///
25/// Blank lines and `#`-prefixed comments are ignored; entries are trimmed.
26///
27/// Args:
28/// * `auths_dir`: Directory holding the `roots` pin file.
29///
30/// Usage:
31/// ```ignore
32/// let roots = load_pinned_roots(&auths_dir)?;
33/// ```
34pub fn load_pinned_roots(auths_dir: &Path) -> std::io::Result<Vec<String>> {
35    let path = roots_path(auths_dir);
36    if !path.exists() {
37        return Ok(Vec::new());
38    }
39    let content = std::fs::read_to_string(&path)?;
40    Ok(parse_roots(&content))
41}
42
43/// Parse pin-file content into the trusted-root set (pure; no I/O).
44///
45/// Args:
46/// * `content`: Raw `roots` file contents.
47///
48/// Usage:
49/// ```ignore
50/// let roots = parse_roots("did:keri:Eabc\n# note\n\n");
51/// assert_eq!(roots, vec!["did:keri:Eabc".to_string()]);
52/// ```
53pub fn parse_roots(content: &str) -> Vec<String> {
54    content
55        .lines()
56        .map(str::trim)
57        .filter(|line| !line.is_empty() && !line.starts_with('#'))
58        .map(str::to_string)
59        .collect()
60}
61
62/// Whether `did` (a `did:keri:` string) is a pinned trusted root.
63///
64/// Args:
65/// * `auths_dir`: Directory holding the `roots` pin file.
66/// * `did`: The candidate root `did:keri:`.
67///
68/// Usage:
69/// ```ignore
70/// if is_pinned_root(&auths_dir, &trailer_root_did)? { /* trust it */ }
71/// ```
72pub fn is_pinned_root(auths_dir: &Path, did: &str) -> std::io::Result<bool> {
73    Ok(load_pinned_roots(auths_dir)?.iter().any(|root| root == did))
74}
75
76/// Pin a trusted root `did:keri:` (idempotent — never duplicates an existing entry).
77///
78/// Creates `auths_dir` if needed. Used by `auths init` to seed the local root.
79///
80/// Args:
81/// * `auths_dir`: Directory holding the `roots` pin file.
82/// * `did`: The root `did:keri:` to pin.
83///
84/// Usage:
85/// ```ignore
86/// add_pinned_root(&auths_dir, &controller_did)?;
87/// ```
88pub fn add_pinned_root(auths_dir: &Path, did: &str) -> std::io::Result<()> {
89    let mut roots = load_pinned_roots(auths_dir)?;
90    if roots.iter().any(|root| root == did) {
91        return Ok(());
92    }
93    roots.push(did.to_string());
94    std::fs::create_dir_all(auths_dir)?;
95    std::fs::write(roots_path(auths_dir), format!("{}\n", roots.join("\n")))?;
96    Ok(())
97}
98
99/// Failure loading the typed pinned-root set: a line that is not a well-formed
100/// `did:keri:` identity DID is rejected (fail-closed) rather than silently skipped.
101#[derive(Debug, thiserror::Error)]
102pub enum RootsError {
103    /// The `roots` pin file could not be read.
104    #[error("could not read roots pin file: {0}")]
105    Io(#[from] std::io::Error),
106
107    /// A non-comment, non-blank line is not a valid `did:keri:` root.
108    #[error("roots pin line {line} ({value:?}) is not a valid did:keri: root: {source}")]
109    MalformedRoot {
110        /// 1-based line number in the pin file.
111        line: usize,
112        /// The offending (trimmed) line content.
113        value: String,
114        /// The underlying DID parse error.
115        #[source]
116        source: auths_verifier::DidParseError,
117    },
118}
119
120/// Parse pin-file content into typed trusted-root identities (pure; no I/O).
121///
122/// Each non-blank, non-`#` line must be a well-formed `did:keri:` identity DID;
123/// a malformed line fails closed with [`RootsError::MalformedRoot`]. The pin file
124/// names **identities only** — capabilities come from the presented credential's
125/// scope seal, never this file.
126///
127/// Args:
128/// * `content`: Raw `roots` file contents.
129///
130/// Usage:
131/// ```ignore
132/// let roots = parse_roots_typed("did:keri:Eabc\n")?;
133/// ```
134pub fn parse_roots_typed(content: &str) -> Result<Vec<IdentityDID>, RootsError> {
135    content
136        .lines()
137        .enumerate()
138        .map(|(idx, raw)| (idx + 1, raw.trim()))
139        .filter(|(_, line)| !line.is_empty() && !line.starts_with('#'))
140        .map(|(line, value)| {
141            IdentityDID::parse(value).map_err(|source| RootsError::MalformedRoot {
142                line,
143                value: value.to_string(),
144                source,
145            })
146        })
147        .collect()
148}
149
150/// Load the pinned trusted-root set as typed [`IdentityDID`]s, failing closed on a
151/// malformed line. Empty when the file is absent.
152///
153/// This is the typed successor to [`load_pinned_roots`]: the relying-party middleware
154/// pins delegation anchors by parsed identity, so a malformed pin is a hard error at
155/// load rather than a silently-dropped root.
156///
157/// Args:
158/// * `auths_dir`: Directory holding the `roots` pin file.
159///
160/// Usage:
161/// ```ignore
162/// let roots = load_pinned_roots_typed(&auths_dir)?;
163/// ```
164pub fn load_pinned_roots_typed(auths_dir: &Path) -> Result<Vec<IdentityDID>, RootsError> {
165    let path = roots_path(auths_dir);
166    if !path.exists() {
167        return Ok(Vec::new());
168    }
169    let content = std::fs::read_to_string(&path)?;
170    parse_roots_typed(&content)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_roots_ignores_blanks_and_comments() {
179        let content = "did:keri:Eaaa\n\n# a comment\n  did:keri:Ebbb  \n";
180        assert_eq!(
181            parse_roots(content),
182            vec!["did:keri:Eaaa".to_string(), "did:keri:Ebbb".to_string()]
183        );
184    }
185
186    #[test]
187    fn typed_roots_parse_valid_did_keri() {
188        let roots = parse_roots_typed("did:keri:Eaaa\n# c\n  did:keri:Ebbb \n").expect("parse");
189        assert_eq!(
190            roots.iter().map(|r| r.as_str()).collect::<Vec<_>>(),
191            ["did:keri:Eaaa", "did:keri:Ebbb"]
192        );
193    }
194
195    #[test]
196    fn typed_roots_reject_malformed_line_fail_closed() {
197        // A non-did:keri line is a hard error (with its line number), never skipped.
198        let err = parse_roots_typed("did:keri:Eaaa\nnot-a-did\n").expect_err("must fail closed");
199        assert!(
200            matches!(err, RootsError::MalformedRoot { line: 2, .. }),
201            "expected MalformedRoot at line 2, got {err:?}"
202        );
203    }
204
205    #[test]
206    fn typed_roots_absent_file_is_empty() {
207        let tmp = tempfile::TempDir::new().expect("temp dir");
208        assert!(
209            load_pinned_roots_typed(tmp.path())
210                .expect("load")
211                .is_empty()
212        );
213    }
214
215    #[test]
216    fn add_and_membership_roundtrip() {
217        let tmp = tempfile::TempDir::new().expect("temp dir");
218        let dir = tmp.path();
219
220        // Absent file → empty + not pinned.
221        assert!(load_pinned_roots(dir).expect("load").is_empty());
222        assert!(!is_pinned_root(dir, "did:keri:Eroot").expect("check"));
223
224        add_pinned_root(dir, "did:keri:Eroot").expect("add");
225        assert!(is_pinned_root(dir, "did:keri:Eroot").expect("check pinned"));
226        assert!(!is_pinned_root(dir, "did:keri:Eother").expect("check unpinned"));
227
228        // Idempotent.
229        add_pinned_root(dir, "did:keri:Eroot").expect("add again");
230        assert_eq!(load_pinned_roots(dir).expect("load").len(), 1);
231
232        // A second distinct root.
233        add_pinned_root(dir, "did:keri:Esecond").expect("add second");
234        assert_eq!(load_pinned_roots(dir).expect("load").len(), 2);
235    }
236}