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/// What [`pin_root_in_repo`] did, so the caller can report it accurately.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum RootPinOutcome {
117    /// The root was newly pinned and the pin file staged.
118    PinnedAndStaged,
119    /// The root was already pinned but the pin file was untracked; it is now staged.
120    Staged,
121    /// The root was already pinned and the pin file already tracked. Nothing to do.
122    AlreadyTracked,
123}
124
125/// Failure pinning a trusted root into a repository.
126#[derive(Debug, thiserror::Error)]
127pub enum PinRootError {
128    /// The pin file could not be read or written.
129    #[error(transparent)]
130    Store(#[from] ConfigStoreError),
131    /// The pin file could not be staged into the index.
132    #[error(transparent)]
133    Index(#[from] crate::ports::git::IndexError),
134}
135
136/// Pin `did` as a trusted root in `<repo_root>/.auths/roots` **and stage it**, so
137/// the declaration travels with the repository.
138///
139/// The pin is the only trust anchor the delegation model permits: an `Auths-Id`
140/// trailer may *select* among pinned roots, never *establish* one. A pin that is
141/// written but never staged therefore never reaches a cloner, and every
142/// third-party verification path dead-ends on it.
143///
144/// Staging (rather than committing) is deliberate: `auths init` must not author a
145/// commit on the user's behalf. The pin rides along with their next commit, which
146/// is the one they are about to make anyway.
147///
148/// Idempotent: staging an unchanged tracked file is a no-op.
149///
150/// Args:
151/// * `store`: File-access port for the pin file.
152/// * `index`: Repository index port used to stage the pin file.
153/// * `repo_root`: The repository's top level (the directory containing `.auths/`).
154/// * `did`: The root `did:keri:` to pin.
155///
156/// Usage:
157/// ```ignore
158/// match pin_root_in_repo(&store, &index, &repo_root, &root_did)? {
159///     RootPinOutcome::AlreadyTracked => {}
160///     _ => println!("pinned — clones can now verify your commits"),
161/// }
162/// ```
163pub fn pin_root_in_repo(
164    store: &dyn ConfigStore,
165    index: &dyn crate::ports::git::RepoIndex,
166    repo_root: &Path,
167    did: &str,
168) -> Result<RootPinOutcome, PinRootError> {
169    let auths_dir = repo_root.join(".auths");
170    let pin_file = roots_path(&auths_dir);
171
172    let already_pinned = is_pinned_root(store, &auths_dir, did)?;
173    if already_pinned && index.is_tracked(&pin_file) {
174        return Ok(RootPinOutcome::AlreadyTracked);
175    }
176
177    if !already_pinned {
178        add_pinned_root(store, &auths_dir, did)?;
179    }
180    index.stage(&pin_file)?;
181
182    Ok(if already_pinned {
183        RootPinOutcome::Staged
184    } else {
185        RootPinOutcome::PinnedAndStaged
186    })
187}
188
189/// Failure loading the typed pinned-root set: a line that is not a well-formed
190/// `did:keri:` identity DID is rejected (fail-closed) rather than silently skipped.
191#[derive(Debug, thiserror::Error)]
192pub enum RootsError {
193    /// The `roots` pin file could not be read.
194    #[error("could not read roots pin file: {0}")]
195    Store(#[from] ConfigStoreError),
196
197    /// A non-comment, non-blank line is not a valid `did:keri:` root.
198    #[error("roots pin line {line} ({value:?}) is not a valid did:keri: root: {source}")]
199    MalformedRoot {
200        /// 1-based line number in the pin file.
201        line: usize,
202        /// The offending (trimmed) line content.
203        value: String,
204        /// The underlying DID parse error.
205        #[source]
206        source: auths_verifier::DidParseError,
207    },
208}
209
210/// Parse pin-file content into typed trusted-root identities (pure; no I/O).
211///
212/// Each non-blank, non-`#` line must be a well-formed `did:keri:` identity DID;
213/// a malformed line fails closed with [`RootsError::MalformedRoot`]. The pin file
214/// names **identities only** — capabilities come from the presented credential's
215/// scope seal, never this file.
216///
217/// Args:
218/// * `content`: Raw `roots` file contents.
219///
220/// Usage:
221/// ```ignore
222/// let roots = parse_roots_typed("did:keri:Eabc\n")?;
223/// ```
224pub fn parse_roots_typed(content: &str) -> Result<Vec<IdentityDID>, RootsError> {
225    content
226        .lines()
227        .enumerate()
228        .map(|(idx, raw)| (idx + 1, raw.trim()))
229        .filter(|(_, line)| !line.is_empty() && !line.starts_with('#'))
230        .map(|(line, value)| {
231            IdentityDID::parse(value).map_err(|source| RootsError::MalformedRoot {
232                line,
233                value: value.to_string(),
234                source,
235            })
236        })
237        .collect()
238}
239
240/// Load the pinned trusted-root set as typed [`IdentityDID`]s, failing closed on a
241/// malformed line. Empty when the file is absent.
242///
243/// This is the typed successor to [`load_pinned_roots`]: the relying-party middleware
244/// pins delegation anchors by parsed identity, so a malformed pin is a hard error at
245/// load rather than a silently-dropped root.
246///
247/// Args:
248/// * `store`: File-access port for the pin file.
249/// * `auths_dir`: Directory holding the `roots` pin file.
250///
251/// Usage:
252/// ```ignore
253/// let roots = load_pinned_roots_typed(&store, &auths_dir)?;
254/// ```
255pub fn load_pinned_roots_typed(
256    store: &dyn ConfigStore,
257    auths_dir: &Path,
258) -> Result<Vec<IdentityDID>, RootsError> {
259    match store.read(&roots_path(auths_dir))? {
260        Some(content) => parse_roots_typed(&content),
261        None => Ok(Vec::new()),
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    struct FsStore;
270
271    impl ConfigStore for FsStore {
272        fn read(&self, path: &Path) -> Result<Option<String>, ConfigStoreError> {
273            match std::fs::read_to_string(path) {
274                Ok(content) => Ok(Some(content)),
275                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
276                Err(e) => Err(ConfigStoreError::Read {
277                    path: path.to_path_buf(),
278                    source: e,
279                }),
280            }
281        }
282
283        fn write(&self, path: &Path, content: &str) -> Result<(), ConfigStoreError> {
284            if let Some(parent) = path.parent() {
285                std::fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
286                    path: path.to_path_buf(),
287                    source: e,
288                })?;
289            }
290            std::fs::write(path, content).map_err(|e| ConfigStoreError::Write {
291                path: path.to_path_buf(),
292                source: e,
293            })
294        }
295    }
296
297    #[test]
298    fn parse_roots_ignores_blanks_and_comments() {
299        let content = "did:keri:Eaaa\n\n# a comment\n  did:keri:Ebbb  \n";
300        assert_eq!(
301            parse_roots(content),
302            vec!["did:keri:Eaaa".to_string(), "did:keri:Ebbb".to_string()]
303        );
304    }
305
306    #[test]
307    fn typed_roots_parse_valid_did_keri() {
308        let roots = parse_roots_typed("did:keri:Eaaa\n# c\n  did:keri:Ebbb \n").expect("parse");
309        assert_eq!(
310            roots.iter().map(|r| r.as_str()).collect::<Vec<_>>(),
311            ["did:keri:Eaaa", "did:keri:Ebbb"]
312        );
313    }
314
315    #[test]
316    fn typed_roots_reject_malformed_line_fail_closed() {
317        // A non-did:keri line is a hard error (with its line number), never skipped.
318        let err = parse_roots_typed("did:keri:Eaaa\nnot-a-did\n").expect_err("must fail closed");
319        assert!(
320            matches!(err, RootsError::MalformedRoot { line: 2, .. }),
321            "expected MalformedRoot at line 2, got {err:?}"
322        );
323    }
324
325    #[test]
326    fn typed_roots_absent_file_is_empty() {
327        let tmp = tempfile::TempDir::new().expect("temp dir");
328        assert!(
329            load_pinned_roots_typed(&FsStore, tmp.path())
330                .expect("load")
331                .is_empty()
332        );
333    }
334
335    #[test]
336    fn add_and_membership_roundtrip() {
337        let tmp = tempfile::TempDir::new().expect("temp dir");
338        let dir = tmp.path();
339        let store = FsStore;
340
341        // Absent file → empty + not pinned.
342        assert!(load_pinned_roots(&store, dir).expect("load").is_empty());
343        assert!(!is_pinned_root(&store, dir, "did:keri:Eroot").expect("check"));
344
345        add_pinned_root(&store, dir, "did:keri:Eroot").expect("add");
346        assert!(is_pinned_root(&store, dir, "did:keri:Eroot").expect("check pinned"));
347        assert!(!is_pinned_root(&store, dir, "did:keri:Eother").expect("check unpinned"));
348
349        // Idempotent.
350        add_pinned_root(&store, dir, "did:keri:Eroot").expect("add again");
351        assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 1);
352
353        // A second distinct root.
354        add_pinned_root(&store, dir, "did:keri:Esecond").expect("add second");
355        assert_eq!(load_pinned_roots(&store, dir).expect("load").len(), 2);
356    }
357
358    /// Records what was staged, and lets a test declare what git already tracks.
359    struct FakeIndex {
360        staged: std::sync::Mutex<Vec<PathBuf>>,
361        tracked: Vec<PathBuf>,
362    }
363
364    impl FakeIndex {
365        fn new() -> Self {
366            Self {
367                staged: std::sync::Mutex::new(Vec::new()),
368                tracked: Vec::new(),
369            }
370        }
371
372        fn with_tracked(tracked: Vec<PathBuf>) -> Self {
373            Self {
374                staged: std::sync::Mutex::new(Vec::new()),
375                tracked,
376            }
377        }
378
379        fn staged(&self) -> Vec<PathBuf> {
380            self.staged.lock().expect("staged lock").clone()
381        }
382    }
383
384    impl crate::ports::git::RepoIndex for FakeIndex {
385        fn stage(&self, path: &Path) -> Result<(), crate::ports::git::IndexError> {
386            self.staged
387                .lock()
388                .expect("staged lock")
389                .push(path.to_path_buf());
390            Ok(())
391        }
392
393        fn is_tracked(&self, path: &Path) -> bool {
394            self.tracked.iter().any(|p| p == path)
395        }
396    }
397
398    #[test]
399    fn pin_root_in_repo_writes_and_stages_the_pin() {
400        let tmp = tempfile::TempDir::new().expect("temp dir");
401        let repo = tmp.path();
402        let index = FakeIndex::new();
403
404        let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
405
406        assert_eq!(outcome, RootPinOutcome::PinnedAndStaged);
407        assert!(is_pinned_root(&FsStore, &repo.join(".auths"), "did:keri:Eroot").expect("pinned"));
408        assert_eq!(
409            index.staged(),
410            vec![repo.join(".auths").join("roots")],
411            "the pin must be staged, or it never reaches a cloner"
412        );
413    }
414
415    /// The regression that made third-party verification impossible: `auths init`
416    /// wrote `.auths/roots` but never staged it, and the file's mere existence then
417    /// made the hook's `[ ! -e ]` guard false forever — so nothing ever staged it
418    /// and the pin never travelled. Pinning must stage an already-written pin.
419    #[test]
420    fn pin_root_in_repo_stages_a_previously_written_but_untracked_pin() {
421        let tmp = tempfile::TempDir::new().expect("temp dir");
422        let repo = tmp.path();
423        let auths_dir = repo.join(".auths");
424
425        // Simulate the old init: pin written, never staged.
426        add_pinned_root(&FsStore, &auths_dir, "did:keri:Eroot").expect("pre-write");
427        let index = FakeIndex::new(); // tracks nothing
428
429        let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
430
431        assert_eq!(outcome, RootPinOutcome::Staged);
432        assert_eq!(
433            index.staged(),
434            vec![auths_dir.join("roots")],
435            "an already-written but untracked pin must still be staged"
436        );
437    }
438
439    #[test]
440    fn pin_root_in_repo_is_a_noop_when_already_pinned_and_tracked() {
441        let tmp = tempfile::TempDir::new().expect("temp dir");
442        let repo = tmp.path();
443        let auths_dir = repo.join(".auths");
444        add_pinned_root(&FsStore, &auths_dir, "did:keri:Eroot").expect("pre-write");
445        let index = FakeIndex::with_tracked(vec![auths_dir.join("roots")]);
446
447        let outcome = pin_root_in_repo(&FsStore, &index, repo, "did:keri:Eroot").expect("pin");
448
449        assert_eq!(outcome, RootPinOutcome::AlreadyTracked);
450        assert!(
451            index.staged().is_empty(),
452            "nothing to do; must not touch the index"
453        );
454    }
455
456    #[test]
457    fn pin_root_in_repo_appends_a_second_root_without_dropping_the_first() {
458        let tmp = tempfile::TempDir::new().expect("temp dir");
459        let repo = tmp.path();
460        let index = FakeIndex::new();
461
462        pin_root_in_repo(&FsStore, &index, repo, "did:keri:Efirst").expect("first");
463        pin_root_in_repo(&FsStore, &index, repo, "did:keri:Esecond").expect("second");
464
465        let roots = load_pinned_roots(&FsStore, &repo.join(".auths")).expect("load");
466        assert_eq!(roots, vec!["did:keri:Efirst", "did:keri:Esecond"]);
467    }
468}