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}