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}