Skip to main content

auths_infra_http/
oobi_resolver.rs

1//! HTTP OOBI client — fetch a `did:keri:` identity's KEL from the static
2//! `.well-known/keri/oobi/<aid>/keri.cesr` layout (see
3//! `docs/architecture/kel-distribution.md`).
4//!
5//! This adapter only *fetches* and parses; it returns raw `Vec<Event>`. The
6//! prefix-binding guard + monotonicity live one layer up (the SDK chain / the
7//! CLI composition root) so they are not reimplemented per transport. Because an
8//! OOBI URL is attacker-influenceable, the client is SSRF-hardened: HTTPS-only,
9//! redirects disabled, private/loopback hosts blocked, and a response-size cap.
10
11use std::net::IpAddr;
12
13use auths_keri::{Event, Prefix};
14use url::Url;
15
16use crate::default_client_builder;
17
18/// Maximum OOBI response body size (DoS bound).
19pub const MAX_OOBI_BYTES: usize = 4 * 1024 * 1024;
20
21/// Errors fetching a KEL over HTTP from an OOBI endpoint.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum HttpOobiError {
25    /// The base URL or constructed OOBI URL is malformed.
26    #[error("invalid OOBI URL: {0}")]
27    InvalidUrl(String),
28
29    /// The URL scheme is not `https` (SSRF guard; bypassed only when private
30    /// hosts are explicitly allowed).
31    #[error("OOBI URL scheme must be https, got '{0}'")]
32    InsecureScheme(String),
33
34    /// The target host is loopback / private / link-local (SSRF guard).
35    #[error("refusing to fetch from a private or loopback host: {0}")]
36    BlockedHost(String),
37
38    /// The HTTP request itself failed (DNS, connect, TLS, timeout).
39    #[error("OOBI request failed: {0}")]
40    Request(String),
41
42    /// 404 — the host serves no KEL for this identifier.
43    #[error("identity not found at OOBI endpoint (404)")]
44    NotFound,
45
46    /// 410 — the identity is gone (revoked/abandoned at the host).
47    #[error("identity gone at OOBI endpoint (410)")]
48    Gone,
49
50    /// 429 — rate limited.
51    #[error("OOBI endpoint rate-limited the request (429)")]
52    RateLimited,
53
54    /// 422 — the server itself reported a prefix mismatch.
55    #[error("OOBI endpoint reported a prefix mismatch (422)")]
56    ServerPrefixMismatch,
57
58    /// Any other non-success status.
59    #[error("unexpected HTTP status {0} from OOBI endpoint")]
60    UnexpectedStatus(u16),
61
62    /// The response exceeded the size cap.
63    #[error("OOBI response exceeds the {0}-byte size cap")]
64    Oversized(usize),
65
66    /// The body was not a parseable KEL.
67    #[error("malformed KEL body from OOBI endpoint: {0}")]
68    Malformed(String),
69}
70
71/// An SSRF-hardened HTTP client for the OOBI static KEL layout.
72pub struct HttpOobiResolver {
73    client: reqwest::Client,
74    base_url: String,
75    allow_private: bool,
76}
77
78impl HttpOobiResolver {
79    /// Build a resolver for `base_url`. The client follows **no** redirects
80    /// (so a 3xx to a private IP cannot exfiltrate) and inherits the hardened
81    /// timeouts / TLS floor.
82    ///
83    /// Args:
84    /// * `base_url`: The OOBI host base (e.g. `https://registry.example`).
85    pub fn new(base_url: impl Into<String>) -> Result<Self, HttpOobiError> {
86        let client = default_client_builder()
87            .redirect(reqwest::redirect::Policy::none())
88            .build()
89            .map_err(|e| HttpOobiError::Request(e.to_string()))?;
90        Ok(Self {
91            client,
92            base_url: base_url.into(),
93            allow_private: false,
94        })
95    }
96
97    /// Allow plain `http` and private/loopback hosts — for intranet registries
98    /// and tests against a local server. Off by default.
99    pub fn allow_private(mut self, allow: bool) -> Self {
100        self.allow_private = allow;
101        self
102    }
103
104    /// Fetch and parse the KEL for `prefix` from the OOBI layout.
105    ///
106    /// Applies the SSRF guard, maps HTTP status to the error taxonomy, enforces
107    /// the size cap, and parses the `keri.cesr` body (a JSON array of events).
108    /// Returns raw events — the caller applies the prefix-binding guard.
109    ///
110    /// Args:
111    /// * `prefix`: The `did:keri:` prefix (AID) to resolve.
112    pub async fn fetch_kel(&self, prefix: &Prefix) -> Result<Vec<Event>, HttpOobiError> {
113        let url = self.oobi_url(prefix)?;
114        self.guard_url(&url)?;
115        let resp = self
116            .client
117            .get(url)
118            .send()
119            .await
120            .map_err(|e| HttpOobiError::Request(e.to_string()))?;
121        map_status(resp.status())?;
122        let bytes = resp
123            .bytes()
124            .await
125            .map_err(|e| HttpOobiError::Request(e.to_string()))?;
126        if bytes.len() > MAX_OOBI_BYTES {
127            return Err(HttpOobiError::Oversized(MAX_OOBI_BYTES));
128        }
129        serde_json::from_slice::<Vec<Event>>(&bytes)
130            .map_err(|e| HttpOobiError::Malformed(e.to_string()))
131    }
132
133    /// Build the OOBI URL for `prefix` under the base.
134    fn oobi_url(&self, prefix: &Prefix) -> Result<Url, HttpOobiError> {
135        let raw = format!(
136            "{}/.well-known/keri/oobi/{}/keri.cesr",
137            self.base_url.trim_end_matches('/'),
138            prefix.as_str()
139        );
140        Url::parse(&raw).map_err(|e| HttpOobiError::InvalidUrl(e.to_string()))
141    }
142
143    /// Enforce the SSRF policy on a resolved URL (no-op when `allow_private`).
144    fn guard_url(&self, url: &Url) -> Result<(), HttpOobiError> {
145        if self.allow_private {
146            return Ok(());
147        }
148        if url.scheme() != "https" {
149            return Err(HttpOobiError::InsecureScheme(url.scheme().to_string()));
150        }
151        match url.host_str() {
152            Some(host) if is_blocked_host(host) => {
153                Err(HttpOobiError::BlockedHost(host.to_string()))
154            }
155            Some(_) => Ok(()),
156            None => Err(HttpOobiError::InvalidUrl("URL has no host".to_string())),
157        }
158    }
159}
160
161/// Whether a host should be refused as an SSRF target. IP literals are classified
162/// directly; the obvious loopback names are blocked. (DNS names that resolve to
163/// private space are mitigated by redirects-disabled + HTTPS-only.)
164fn is_blocked_host(host: &str) -> bool {
165    if let Ok(ip) = host.parse::<IpAddr>() {
166        return is_blocked_ip(ip);
167    }
168    matches!(host, "localhost" | "localhost.localdomain")
169}
170
171/// Whether an IP is loopback / private / link-local / unspecified.
172fn is_blocked_ip(ip: IpAddr) -> bool {
173    match ip {
174        IpAddr::V4(v4) => {
175            v4.is_loopback()
176                || v4.is_private()
177                || v4.is_link_local()
178                || v4.is_unspecified()
179                || v4.is_broadcast()
180        }
181        IpAddr::V6(v6) => {
182            let first = v6.segments()[0];
183            v6.is_loopback()
184                || v6.is_unspecified()
185                || (first & 0xfe00) == 0xfc00 // unique-local fc00::/7
186                || (first & 0xffc0) == 0xfe80 // link-local fe80::/10
187        }
188    }
189}
190
191/// Map a non-2xx HTTP status to the OOBI error taxonomy (`Ok` for any 2xx).
192fn map_status(status: reqwest::StatusCode) -> Result<(), HttpOobiError> {
193    use reqwest::StatusCode;
194    if status.is_success() {
195        return Ok(());
196    }
197    Err(match status {
198        StatusCode::NOT_FOUND => HttpOobiError::NotFound,
199        StatusCode::GONE => HttpOobiError::Gone,
200        StatusCode::TOO_MANY_REQUESTS => HttpOobiError::RateLimited,
201        StatusCode::UNPROCESSABLE_ENTITY => HttpOobiError::ServerPrefixMismatch,
202        other => HttpOobiError::UnexpectedStatus(other.as_u16()),
203    })
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209    use super::*;
210    use auths_keri::{
211        CesrKey, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold, VersionString,
212        finalize_icp_event,
213    };
214
215    fn icp_and_prefix() -> (Event, Prefix) {
216        let key = KeriPublicKey::ed25519(&[11u8; 32]).unwrap();
217        // A valid next-commitment digest (the SAID of any key works structurally).
218        let next = KeriPublicKey::ed25519(&[12u8; 32]).unwrap();
219        let n = auths_core::crypto::said::compute_next_commitment(&next);
220        let icp = IcpEvent {
221            v: VersionString::placeholder(),
222            d: Said::default(),
223            i: Prefix::default(),
224            s: KeriSequence::new(0),
225            kt: Threshold::Simple(1),
226            k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
227            nt: Threshold::Simple(1),
228            n: vec![n],
229            bt: Threshold::Simple(0),
230            b: vec![],
231            c: vec![],
232            a: vec![],
233        };
234        let finalized = finalize_icp_event(icp).unwrap();
235        let prefix = finalized.i.clone();
236        (Event::Icp(finalized), prefix)
237    }
238
239    #[test]
240    fn blocks_loopback_and_private_and_insecure() {
241        assert!(is_blocked_host("127.0.0.1"));
242        assert!(is_blocked_host("10.0.0.5"));
243        assert!(is_blocked_host("192.168.1.1"));
244        assert!(is_blocked_host("169.254.1.1"));
245        assert!(is_blocked_host("localhost"));
246        assert!(is_blocked_host("::1"));
247        assert!(!is_blocked_host("93.184.216.34")); // example.com
248        assert!(!is_blocked_host("registry.example.com"));
249
250        let resolver = HttpOobiResolver::new("https://127.0.0.1").unwrap();
251        let (_e, prefix) = icp_and_prefix();
252        let err = resolver
253            .guard_url(&resolver.oobi_url(&prefix).unwrap())
254            .unwrap_err();
255        assert!(matches!(err, HttpOobiError::BlockedHost(_)));
256
257        let insecure = HttpOobiResolver::new("http://registry.example.com").unwrap();
258        let err = insecure
259            .guard_url(&insecure.oobi_url(&prefix).unwrap())
260            .unwrap_err();
261        assert!(matches!(err, HttpOobiError::InsecureScheme(_)));
262    }
263
264    #[test]
265    fn maps_http_status_to_taxonomy() {
266        use reqwest::StatusCode;
267        assert!(map_status(StatusCode::OK).is_ok());
268        assert!(matches!(
269            map_status(StatusCode::NOT_FOUND),
270            Err(HttpOobiError::NotFound)
271        ));
272        assert!(matches!(
273            map_status(StatusCode::GONE),
274            Err(HttpOobiError::Gone)
275        ));
276        assert!(matches!(
277            map_status(StatusCode::TOO_MANY_REQUESTS),
278            Err(HttpOobiError::RateLimited)
279        ));
280        assert!(matches!(
281            map_status(StatusCode::UNPROCESSABLE_ENTITY),
282            Err(HttpOobiError::ServerPrefixMismatch)
283        ));
284        assert!(matches!(
285            map_status(StatusCode::INTERNAL_SERVER_ERROR),
286            Err(HttpOobiError::UnexpectedStatus(500))
287        ));
288    }
289
290    #[test]
291    fn builds_well_known_oobi_url() {
292        let resolver = HttpOobiResolver::new("https://registry.example/").unwrap();
293        let prefix = Prefix::new_unchecked("EabcDEF123".to_string());
294        let url = resolver.oobi_url(&prefix).unwrap();
295        assert_eq!(
296            url.as_str(),
297            "https://registry.example/.well-known/keri/oobi/EabcDEF123/keri.cesr"
298        );
299    }
300
301    #[tokio::test]
302    async fn fetches_kel_from_static_layout() {
303        use axum::Router;
304        use axum::routing::get;
305
306        let (event, prefix) = icp_and_prefix();
307        let events = vec![event];
308        let body = serde_json::to_vec(&events).unwrap();
309
310        let app = Router::new().route(
311            "/.well-known/keri/oobi/{aid}/keri.cesr",
312            get(move || {
313                let body = body.clone();
314                async move { body }
315            }),
316        );
317        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
318        let addr = listener.local_addr().unwrap();
319        tokio::spawn(async move {
320            axum::serve(listener, app).await.unwrap();
321        });
322
323        let resolver = HttpOobiResolver::new(format!("http://{addr}"))
324            .unwrap()
325            .allow_private(true);
326        let fetched = resolver.fetch_kel(&prefix).await.unwrap();
327        assert_eq!(fetched, events);
328    }
329}