use chrono::{DateTime, Utc};
use url::Url;
use crate::jose::{DefaultVerifier, JwsVerifier};
use crate::methods::web::{HttpClient, HttpResponse};
use crate::resolver::{KeyReference, KeyResolver, KeyRole};
use crate::vc::Jwt;
use crate::vc::validation::{CredentialStatus, StatusCheck, StatusError};
use super::{StatusListError, StatusListToken, StatusMechanism};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub struct StatusListCheck<C, R, V = DefaultVerifier> {
client: C,
resolver: R,
verifier: V,
}
impl<C, R, V> StatusListCheck<C, R, V> {
pub const MAX_REDIRECTS: usize = 3;
}
impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> StatusListCheck<C, R, V> {
pub fn new(client: C, resolver: R, verifier: V) -> Self {
Self {
client,
resolver,
verifier,
}
}
async fn get(&self, uri: &str) -> Result<HttpResponse, StatusError> {
let unavailable = |reason: String, source: Option<BoxError>| StatusError::Unavailable {
uri: uri.to_owned(),
reason,
source,
};
let mut url = Url::parse(uri).map_err(|e| StatusError::InvalidList {
uri: uri.to_owned(),
reason: "the URI is not a URL".to_owned(),
source: Some(Box::new(e)),
})?;
let mut visited = vec![url.clone()];
loop {
let response = self
.client
.get_accepting(&url, StatusListToken::MEDIA_TYPE)
.await
.map_err(|e| unavailable(format!("fetching {url}"), Some(Box::new(e))))?;
if !matches!(response.status, 301 | 302 | 303 | 307 | 308) {
return Ok(response);
}
if visited.len() > Self::MAX_REDIRECTS {
return Err(unavailable(
format!("more than {} redirects", Self::MAX_REDIRECTS),
None,
));
}
let location = response.location.ok_or_else(|| {
unavailable(
format!(
"{url} answered HTTP {} without a `Location`",
response.status
),
None,
)
})?;
let next = url.join(&location).map_err(|e| {
unavailable(
format!("redirect from {url} to {location:?}"),
Some(Box::new(e)),
)
})?;
if next.scheme() != "https" {
return Err(unavailable(
format!("redirect from {url} to {next}, which is not HTTPS"),
None,
));
}
if visited.contains(&next) {
return Err(unavailable(format!("redirect loop at {next}"), None));
}
visited.push(next.clone());
url = next;
}
}
async fn fetch(&self, uri: &str) -> Result<StatusListToken, StatusError> {
let invalid = |reason: &str, source: Option<BoxError>| StatusError::InvalidList {
uri: uri.to_owned(),
reason: reason.to_owned(),
source,
};
let response = self.get(uri).await?;
if !(200..300).contains(&response.status) {
return Err(StatusError::Unavailable {
uri: uri.to_owned(),
reason: format!("HTTP {}", response.status),
source: None,
});
}
let body = String::from_utf8(response.body)
.map_err(|e| invalid("the body is not UTF-8", Some(Box::new(e))))?;
let not_a_token = |e: BoxError| invalid("not a status list token", Some(e));
let jwt = Jwt::new(body.trim()).map_err(|e| not_a_token(Box::new(e)))?;
let (_, token) = StatusListToken::decode(&jwt).map_err(|e| not_a_token(Box::new(e)))?;
let jws = jwt.decode().map_err(|e| not_a_token(Box::new(e)))?;
let iss = token.additional.get("iss").and_then(|iss| iss.as_str());
let reference = KeyReference::from_jws(&jws, iss)
.map_err(|e| invalid("no key to verify it with", Some(Box::new(e))))?;
let key = self
.resolver
.resolve_key(&reference, KeyRole::Issuer)
.await
.map_err(|e| StatusError::UnresolvedKey {
uri: uri.to_owned(),
key: reference.to_string(),
source: e,
})?;
jws.verify(&self.verifier, &key)
.map_err(|e| invalid("the signature does not verify", Some(Box::new(e))))?;
let bound = match iss {
Some(iss) => reference.binds_to(Some(iss), &key),
None => matches!(reference, KeyReference::DidUrl(_)),
};
if !bound {
return Err(invalid(
&format!("key {reference} does not belong to the issuer {iss:?}"),
None,
));
}
Ok(token)
}
}
#[async_trait::async_trait]
impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> StatusCheck for StatusListCheck<C, R, V> {
async fn check(
&self,
status: CredentialStatus<'_>,
now: DateTime<Utc>,
) -> Result<(), StatusError> {
let CredentialStatus::StatusClaim(status) = status else {
return Err(StatusError::Unsupported(status.mechanism()));
};
let Some(reference) = &status.status_list else {
let names: Vec<&str> = status
.mechanisms()
.filter_map(|m| match m {
StatusMechanism::Other { name, .. } => Some(name),
_ => None,
})
.collect();
return Err(StatusError::Unsupported(names.join(", ")));
};
let uri = &reference.uri;
let token = self.fetch(uri).await?;
let status_type = token.status_of(reference, now).map_err(|e| match e {
StatusListError::Expired { expired_at, now } => StatusError::Expired {
uri: uri.clone(),
expired_at,
now,
},
StatusListError::IndexOutOfRange { idx, len } => StatusError::IndexOutOfRange {
uri: uri.clone(),
idx,
len,
},
other => StatusError::InvalidList {
uri: uri.clone(),
reason: "it does not cover the reference".to_owned(),
source: Some(Box::new(other)),
},
})?;
if status_type.is_valid() {
Ok(())
} else {
Err(StatusError::Invalid(status_type.to_string()))
}
}
}
impl<C: core::fmt::Debug, R: core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug
for StatusListCheck<C, R, V>
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("StatusListCheck")
.field("client", &self.client)
.field("resolver", &self.resolver)
.field("verifier", &self.verifier)
.finish()
}
}