auths_infra_http/
oobi_resolver.rs1use std::net::IpAddr;
12
13use auths_keri::{Event, Prefix};
14use url::Url;
15
16use crate::default_client_builder;
17
18pub const MAX_OOBI_BYTES: usize = 4 * 1024 * 1024;
20
21#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum HttpOobiError {
25 #[error("invalid OOBI URL: {0}")]
27 InvalidUrl(String),
28
29 #[error("OOBI URL scheme must be https, got '{0}'")]
32 InsecureScheme(String),
33
34 #[error("refusing to fetch from a private or loopback host: {0}")]
36 BlockedHost(String),
37
38 #[error("OOBI request failed: {0}")]
40 Request(String),
41
42 #[error("identity not found at OOBI endpoint (404)")]
44 NotFound,
45
46 #[error("identity gone at OOBI endpoint (410)")]
48 Gone,
49
50 #[error("OOBI endpoint rate-limited the request (429)")]
52 RateLimited,
53
54 #[error("OOBI endpoint reported a prefix mismatch (422)")]
56 ServerPrefixMismatch,
57
58 #[error("unexpected HTTP status {0} from OOBI endpoint")]
60 UnexpectedStatus(u16),
61
62 #[error("OOBI response exceeds the {0}-byte size cap")]
64 Oversized(usize),
65
66 #[error("malformed KEL body from OOBI endpoint: {0}")]
68 Malformed(String),
69}
70
71pub struct HttpOobiResolver {
73 client: reqwest::Client,
74 base_url: String,
75 allow_private: bool,
76}
77
78impl HttpOobiResolver {
79 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 pub fn allow_private(mut self, allow: bool) -> Self {
100 self.allow_private = allow;
101 self
102 }
103
104 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 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 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
161fn 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
171fn 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 || (first & 0xffc0) == 0xfe80 }
188 }
189}
190
191fn 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 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")); 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}