Skip to main content

auths_cli/commands/
oobi.rs

1//! `auths oobi` — KERI Out-Of-Band Introduction (discovery).
2//!
3//! An OOBI is how KERI controllers discover each other: a URL that says *"here
4//! is my AID, and here is where to fetch its key event log and endpoints."* It
5//! is the bootstrap of every live KERI exchange (witnessing, credential
6//! presentation, key-state resolution) — before a peer can talk to an AID it
7//! must first discover *where* that AID lives, out of band. The KEL fetched
8//! through an OOBI is still verified by replay, so the URL is only a location
9//! hint, never a root of trust.
10//!
11//! Two directions, mirroring discovery itself:
12//!
13//! * `auths oobi resolve` — peer → us: parse a peer's OOBI URL, fetch the bytes
14//!   it points at, replay the embedded KEL into a verified key-state, and print
15//!   it. `--from-file` resolves an already-fetched stream offline.
16//! * `auths oobi endpoint` — us → peer: from one of our KELs and the URL we host
17//!   it at, emit the OOBI URL to publish plus the `rpy` reply stream
18//!   (`/loc/scheme` + `/end/role/add`) a peer fetches when it resolves us.
19//!
20//! The wire definitions (URL grammar, reply records, KEL ingest) live in
21//! `auths-keri::oobi`; this is a thin CLI adapter. The HTTP fetch sits behind a
22//! port here so the discovery logic never imports a transport.
23
24use std::path::PathBuf;
25use std::time::Duration;
26
27use anyhow::{Result, anyhow};
28use auths_keri::{Oobi, OobiEndpoint, TrustedKel, ingest_oobi_stream, parse_kel_json};
29use auths_utils::path::expand_tilde;
30use clap::{Parser, Subcommand};
31
32use crate::config::CliConfig;
33
34/// Resolve or serve a KERI OOBI (Out-Of-Band Introduction) for discovery.
35#[derive(Parser, Debug, Clone)]
36#[command(
37    about = "Resolve or serve a KERI OOBI — discovery, interoperable with keripy/KERIA",
38    after_help = "Examples:
39  auths oobi resolve --url http://peer:5642/oobi/EOoC.../controller
40  auths oobi resolve --url http://peer/oobi/EOoC.../witness --from-file stream.cesr
41  auths oobi endpoint --from-kel kel.json --authority 127.0.0.1:5642 --url http://127.0.0.1:5642/"
42)]
43pub struct OobiCommand {
44    /// The OOBI direction to run.
45    #[command(subcommand)]
46    pub action: OobiAction,
47}
48
49/// The two OOBI directions: resolve a peer's, or serve our own.
50#[derive(Subcommand, Debug, Clone)]
51pub enum OobiAction {
52    /// Resolve a peer's OOBI URL → fetch + replay its KEL → print the key-state.
53    Resolve(ResolveArgs),
54    /// Serve an AID: emit its OOBI URL + the `rpy` reply stream a peer fetches.
55    Endpoint(EndpointArgs),
56}
57
58/// `auths oobi resolve` — discover a peer by resolving its OOBI URL.
59#[derive(Parser, Debug, Clone)]
60pub struct ResolveArgs {
61    /// The peer's OOBI URL: `<scheme>://<authority>/oobi/<cid>/<role>[/<eid>]`.
62    #[clap(long, value_name = "OOBI_URL")]
63    pub url: String,
64
65    /// Resolve an already-fetched stream from this file instead of an HTTP
66    /// fetch — the offline/hermetic path (the bytes a live endpoint would
67    /// return). The KEL is still replayed and verified.
68    #[clap(long, value_name = "STREAM.json")]
69    pub from_file: Option<PathBuf>,
70
71    /// HTTP fetch timeout in seconds (live resolve only).
72    #[clap(long, default_value_t = 30)]
73    pub timeout: u64,
74}
75
76/// `auths oobi endpoint` — serve an AID's introduction.
77#[derive(Parser, Debug, Clone)]
78pub struct EndpointArgs {
79    /// Replay this KEL and serve its controller as a discoverable AID.
80    #[clap(long, value_name = "KEL.json")]
81    pub from_kel: PathBuf,
82
83    /// URL scheme to publish the endpoint under (`http`/`https`/`tcp`).
84    #[clap(long, default_value = "http")]
85    pub scheme: String,
86
87    /// Network authority (`host[:port]`) hosting the endpoint — the part of the
88    /// OOBI URL before `/oobi`.
89    #[clap(long, value_name = "HOST:PORT")]
90    pub authority: String,
91
92    /// Absolute endpoint URL embedded in the `/loc/scheme` reply. Defaults to
93    /// `<scheme>://<authority>/` when omitted.
94    #[clap(long, value_name = "URL")]
95    pub url: Option<String>,
96
97    /// Timestamp (RFC 3339) to stamp the `rpy` replies with. Defaults to the
98    /// epoch so output stays deterministic; pass the real `now` to publish.
99    #[clap(long, default_value = "1970-01-01T00:00:00.000000+00:00")]
100    pub dt: String,
101}
102
103impl OobiCommand {
104    /// Run the command (resolve a peer or serve an endpoint).
105    pub fn execute(&self, _ctx: &CliConfig) -> Result<()> {
106        match &self.action {
107            OobiAction::Resolve(args) => args.run(),
108            OobiAction::Endpoint(args) => args.run(),
109        }
110    }
111}
112
113impl ResolveArgs {
114    fn run(&self) -> Result<()> {
115        // Parse the URL at the boundary — an invalid OOBI never reaches the
116        // fetch. `cid` is the AID the URL claims to introduce; ingest binds the
117        // replayed KEL to it.
118        let oobi = Oobi::parse(&self.url).map_err(|e| anyhow!("parse OOBI URL: {e}"))?;
119
120        let stream = match &self.from_file {
121            Some(path) => {
122                let path = expand_tilde(path)?;
123                std::fs::read_to_string(&path)
124                    .map_err(|e| anyhow!("read OOBI stream {}: {e}", path.display()))?
125            }
126            None => fetch_oobi(&oobi.url(), self.timeout)?,
127        };
128
129        let resolution = ingest_oobi_stream(&oobi.cid, &stream)
130            .map_err(|e| anyhow!("resolve OOBI {}: {e}", oobi.url()))?;
131
132        eprintln!(
133            "resolved OOBI {} → {} ({} KEL event{}, seq {})",
134            oobi.url(),
135            resolution.cid,
136            resolution.event_count,
137            if resolution.event_count == 1 { "" } else { "s" },
138            resolution.state.sequence,
139        );
140        println!("{}", serde_json::to_string_pretty(&resolution.state)?);
141        Ok(())
142    }
143}
144
145impl EndpointArgs {
146    fn run(&self) -> Result<()> {
147        let kel_path = expand_tilde(&self.from_kel)?;
148        let json = std::fs::read_to_string(&kel_path)
149            .map_err(|e| anyhow!("read KEL {}: {e}", kel_path.display()))?;
150        let events = parse_kel_json(&json).map_err(|e| anyhow!("parse KEL: {e}"))?;
151        // A KEL file the operator hands us is a local, self-owned artifact — the
152        // reviewable trust assertion that structural replay requires.
153        let state = TrustedKel::from_trusted_source(&events)
154            .replay()
155            .map_err(|e| anyhow!("replay KEL: {e}"))?;
156
157        let url = self
158            .url
159            .clone()
160            .unwrap_or_else(|| format!("{}://{}/", self.scheme, self.authority));
161        let endpoint = OobiEndpoint::for_controller(
162            &state,
163            self.scheme.clone(),
164            self.authority.clone(),
165            url,
166            self.dt.clone(),
167        )
168        .map_err(|e| anyhow!("derive OOBI endpoint: {e}"))?;
169
170        // The OOBI URL a peer resolves to discover this AID, then the `rpy`
171        // reply stream that resolution returns (the KEL is served separately by
172        // the host endpoint; these are the endpoint-authorization records).
173        println!("{}", endpoint.oobi.url());
174        println!(
175            "{}",
176            endpoint
177                .reply_stream()
178                .map_err(|e| anyhow!("serialize OOBI reply stream: {e}"))?
179        );
180        Ok(())
181    }
182}
183
184/// Fetch the bytes an OOBI URL points at over HTTP — the transport adapter for
185/// the resolve port. Blocking, since the CLI is synchronous.
186fn fetch_oobi(url: &str, timeout_secs: u64) -> Result<String> {
187    let client = reqwest::blocking::Client::builder()
188        .timeout(Duration::from_secs(timeout_secs))
189        .build()
190        .map_err(|e| anyhow!("build HTTP client: {e}"))?;
191    let resp = client
192        .get(url)
193        .send()
194        .map_err(|e| anyhow!("fetch OOBI {url}: {e}"))?;
195    if !resp.status().is_success() {
196        return Err(anyhow!("fetch OOBI {url}: HTTP {}", resp.status()));
197    }
198    resp.text()
199        .map_err(|e| anyhow!("read OOBI response body from {url}: {e}"))
200}