use std::net::IpAddr;
use auths_keri::{Event, Prefix};
use url::Url;
use crate::default_client_builder;
pub const MAX_OOBI_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HttpOobiError {
#[error("invalid OOBI URL: {0}")]
InvalidUrl(String),
#[error("OOBI URL scheme must be https, got '{0}'")]
InsecureScheme(String),
#[error("refusing to fetch from a private or loopback host: {0}")]
BlockedHost(String),
#[error("OOBI request failed: {0}")]
Request(String),
#[error("identity not found at OOBI endpoint (404)")]
NotFound,
#[error("identity gone at OOBI endpoint (410)")]
Gone,
#[error("OOBI endpoint rate-limited the request (429)")]
RateLimited,
#[error("OOBI endpoint reported a prefix mismatch (422)")]
ServerPrefixMismatch,
#[error("unexpected HTTP status {0} from OOBI endpoint")]
UnexpectedStatus(u16),
#[error("OOBI response exceeds the {0}-byte size cap")]
Oversized(usize),
#[error("malformed KEL body from OOBI endpoint: {0}")]
Malformed(String),
}
pub struct HttpOobiResolver {
client: reqwest::Client,
base_url: String,
allow_private: bool,
}
impl HttpOobiResolver {
pub fn new(base_url: impl Into<String>) -> Result<Self, HttpOobiError> {
let client = default_client_builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| HttpOobiError::Request(e.to_string()))?;
Ok(Self {
client,
base_url: base_url.into(),
allow_private: false,
})
}
pub fn allow_private(mut self, allow: bool) -> Self {
self.allow_private = allow;
self
}
pub async fn fetch_kel(&self, prefix: &Prefix) -> Result<Vec<Event>, HttpOobiError> {
let url = self.oobi_url(prefix)?;
self.guard_url(&url)?;
let resp = self
.client
.get(url)
.send()
.await
.map_err(|e| HttpOobiError::Request(e.to_string()))?;
map_status(resp.status())?;
let bytes = resp
.bytes()
.await
.map_err(|e| HttpOobiError::Request(e.to_string()))?;
if bytes.len() > MAX_OOBI_BYTES {
return Err(HttpOobiError::Oversized(MAX_OOBI_BYTES));
}
serde_json::from_slice::<Vec<Event>>(&bytes)
.map_err(|e| HttpOobiError::Malformed(e.to_string()))
}
fn oobi_url(&self, prefix: &Prefix) -> Result<Url, HttpOobiError> {
let raw = format!(
"{}/.well-known/keri/oobi/{}/keri.cesr",
self.base_url.trim_end_matches('/'),
prefix.as_str()
);
Url::parse(&raw).map_err(|e| HttpOobiError::InvalidUrl(e.to_string()))
}
fn guard_url(&self, url: &Url) -> Result<(), HttpOobiError> {
if self.allow_private {
return Ok(());
}
if url.scheme() != "https" {
return Err(HttpOobiError::InsecureScheme(url.scheme().to_string()));
}
match url.host_str() {
Some(host) if is_blocked_host(host) => {
Err(HttpOobiError::BlockedHost(host.to_string()))
}
Some(_) => Ok(()),
None => Err(HttpOobiError::InvalidUrl("URL has no host".to_string())),
}
}
}
fn is_blocked_host(host: &str) -> bool {
if let Ok(ip) = host.parse::<IpAddr>() {
return is_blocked_ip(ip);
}
matches!(host, "localhost" | "localhost.localdomain")
}
fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
}
IpAddr::V6(v6) => {
let first = v6.segments()[0];
v6.is_loopback()
|| v6.is_unspecified()
|| (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80 }
}
}
fn map_status(status: reqwest::StatusCode) -> Result<(), HttpOobiError> {
use reqwest::StatusCode;
if status.is_success() {
return Ok(());
}
Err(match status {
StatusCode::NOT_FOUND => HttpOobiError::NotFound,
StatusCode::GONE => HttpOobiError::Gone,
StatusCode::TOO_MANY_REQUESTS => HttpOobiError::RateLimited,
StatusCode::UNPROCESSABLE_ENTITY => HttpOobiError::ServerPrefixMismatch,
other => HttpOobiError::UnexpectedStatus(other.as_u16()),
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use auths_keri::{
CesrKey, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold, VersionString,
finalize_icp_event,
};
fn icp_and_prefix() -> (Event, Prefix) {
let key = KeriPublicKey::ed25519(&[11u8; 32]).unwrap();
let next = KeriPublicKey::ed25519(&[12u8; 32]).unwrap();
let n = auths_core::crypto::said::compute_next_commitment(&next);
let icp = IcpEvent {
v: VersionString::placeholder(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: Threshold::Simple(1),
k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
nt: Threshold::Simple(1),
n: vec![n],
bt: Threshold::Simple(0),
b: vec![],
c: vec![],
a: vec![],
};
let finalized = finalize_icp_event(icp).unwrap();
let prefix = finalized.i.clone();
(Event::Icp(finalized), prefix)
}
#[test]
fn blocks_loopback_and_private_and_insecure() {
assert!(is_blocked_host("127.0.0.1"));
assert!(is_blocked_host("10.0.0.5"));
assert!(is_blocked_host("192.168.1.1"));
assert!(is_blocked_host("169.254.1.1"));
assert!(is_blocked_host("localhost"));
assert!(is_blocked_host("::1"));
assert!(!is_blocked_host("93.184.216.34")); assert!(!is_blocked_host("registry.example.com"));
let resolver = HttpOobiResolver::new("https://127.0.0.1").unwrap();
let (_e, prefix) = icp_and_prefix();
let err = resolver
.guard_url(&resolver.oobi_url(&prefix).unwrap())
.unwrap_err();
assert!(matches!(err, HttpOobiError::BlockedHost(_)));
let insecure = HttpOobiResolver::new("http://registry.example.com").unwrap();
let err = insecure
.guard_url(&insecure.oobi_url(&prefix).unwrap())
.unwrap_err();
assert!(matches!(err, HttpOobiError::InsecureScheme(_)));
}
#[test]
fn maps_http_status_to_taxonomy() {
use reqwest::StatusCode;
assert!(map_status(StatusCode::OK).is_ok());
assert!(matches!(
map_status(StatusCode::NOT_FOUND),
Err(HttpOobiError::NotFound)
));
assert!(matches!(
map_status(StatusCode::GONE),
Err(HttpOobiError::Gone)
));
assert!(matches!(
map_status(StatusCode::TOO_MANY_REQUESTS),
Err(HttpOobiError::RateLimited)
));
assert!(matches!(
map_status(StatusCode::UNPROCESSABLE_ENTITY),
Err(HttpOobiError::ServerPrefixMismatch)
));
assert!(matches!(
map_status(StatusCode::INTERNAL_SERVER_ERROR),
Err(HttpOobiError::UnexpectedStatus(500))
));
}
#[test]
fn builds_well_known_oobi_url() {
let resolver = HttpOobiResolver::new("https://registry.example/").unwrap();
let prefix = Prefix::new_unchecked("EabcDEF123".to_string());
let url = resolver.oobi_url(&prefix).unwrap();
assert_eq!(
url.as_str(),
"https://registry.example/.well-known/keri/oobi/EabcDEF123/keri.cesr"
);
}
#[tokio::test]
async fn fetches_kel_from_static_layout() {
use axum::Router;
use axum::routing::get;
let (event, prefix) = icp_and_prefix();
let events = vec![event];
let body = serde_json::to_vec(&events).unwrap();
let app = Router::new().route(
"/.well-known/keri/oobi/{aid}/keri.cesr",
get(move || {
let body = body.clone();
async move { body }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let resolver = HttpOobiResolver::new(format!("http://{addr}"))
.unwrap()
.allow_private(true);
let fetched = resolver.fetch_kel(&prefix).await.unwrap();
assert_eq!(fetched, events);
}
}