Skip to main content

identity_kit/status_list/
check.rs

1//! [`StatusListCheck`]: the Token Status List as a [`StatusCheck`], over an [`HttpClient`].
2
3use chrono::{DateTime, Utc};
4use url::Url;
5
6use crate::jose::{DefaultVerifier, JwsVerifier};
7use crate::methods::web::{HttpClient, HttpResponse};
8use crate::resolver::{KeyReference, KeyResolver, KeyRole};
9use crate::vc::Jwt;
10use crate::vc::validation::{CredentialStatus, StatusCheck, StatusError};
11
12use super::{StatusListError, StatusListToken, StatusMechanism};
13
14type BoxError = Box<dyn std::error::Error + Send + Sync>;
15
16/// A [`StatusCheck`] for the Token Status List: for the `status` claim of an SD-JWT
17/// credential or of a JWT credential ([`CredentialStatus::StatusClaim`]), fetches the status
18/// list token from the referenced URI with an [`HttpClient`], verifies it was signed by
19/// the key its header names ([`KeyReference::from_jws`] with the token's `iss`), resolved
20/// for [`KeyRole::Issuer`], and reads the status at the referenced index (Token Status
21/// List ยง8).
22///
23/// When the token has an `iss`, the key must belong to it ([`KeyReference::binds_to`]);
24/// without one, only a key named by a DID URL is accepted, since a key by value or a key
25/// set could be anybody's. Beyond that the check trusts the key the reference resolves
26/// to; whether that key may speak for the list is the application's policy to enforce
27/// around it, by restricting the resolver, say.
28///
29/// # Fetching
30///
31/// Every request asks for `application/statuslist+jwt` ([`StatusListToken::MEDIA_TYPE`])
32/// through [`HttpClient::get_accepting`]. `ReqwestClient` (feature `reqwest`) also
33/// negotiates gzip, which the draft recommends Status Providers use.
34///
35/// A 301, 302, 303, 307 or 308 response is followed, as the draft recommends: its
36/// [`location`](HttpResponse::location) is resolved against the URL
37/// that returned it and fetched with a new request through the same client, so that every
38/// hop passes the client's checks. The check refuses a target that is not `https`, a URL
39/// already visited (a loop), a redirect without a `Location`, and more than
40/// [`MAX_REDIRECTS`](Self::MAX_REDIRECTS) redirects.
41///
42/// The status list URI comes from the credential, and a redirect target from whoever serves
43/// it, so the client fetches whatever an issuer names: `client` must enforce the protections
44/// `ReqwestClient` does (HTTPS only, no redirects of its own, no non-public addresses after
45/// name resolution, a body limit and a timeout), or the status check becomes a request
46/// forgery primitive.
47pub struct StatusListCheck<C, R, V = DefaultVerifier> {
48    client: C,
49    resolver: R,
50    verifier: V,
51}
52
53impl<C, R, V> StatusListCheck<C, R, V> {
54    /// The most redirects one status list fetch follows.
55    pub const MAX_REDIRECTS: usize = 3;
56}
57
58impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> StatusListCheck<C, R, V> {
59    /// A check fetching with `client`, resolving the status issuer's key with `resolver`
60    /// and verifying with `verifier`.
61    pub fn new(client: C, resolver: R, verifier: V) -> Self {
62        Self {
63            client,
64            resolver,
65            verifier,
66        }
67    }
68
69    /// The response for `uri`, following redirects as the type documentation describes.
70    async fn get(&self, uri: &str) -> Result<HttpResponse, StatusError> {
71        let unavailable = |reason: String, source: Option<BoxError>| StatusError::Unavailable {
72            uri: uri.to_owned(),
73            reason,
74            source,
75        };
76        let mut url = Url::parse(uri).map_err(|e| StatusError::InvalidList {
77            uri: uri.to_owned(),
78            reason: "the URI is not a URL".to_owned(),
79            source: Some(Box::new(e)),
80        })?;
81        let mut visited = vec![url.clone()];
82        loop {
83            let response = self
84                .client
85                .get_accepting(&url, StatusListToken::MEDIA_TYPE)
86                .await
87                .map_err(|e| unavailable(format!("fetching {url}"), Some(Box::new(e))))?;
88            if !matches!(response.status, 301 | 302 | 303 | 307 | 308) {
89                return Ok(response);
90            }
91            if visited.len() > Self::MAX_REDIRECTS {
92                return Err(unavailable(
93                    format!("more than {} redirects", Self::MAX_REDIRECTS),
94                    None,
95                ));
96            }
97            let location = response.location.ok_or_else(|| {
98                unavailable(
99                    format!(
100                        "{url} answered HTTP {} without a `Location`",
101                        response.status
102                    ),
103                    None,
104                )
105            })?;
106            let next = url.join(&location).map_err(|e| {
107                unavailable(
108                    format!("redirect from {url} to {location:?}"),
109                    Some(Box::new(e)),
110                )
111            })?;
112            if next.scheme() != "https" {
113                return Err(unavailable(
114                    format!("redirect from {url} to {next}, which is not HTTPS"),
115                    None,
116                ));
117            }
118            if visited.contains(&next) {
119                return Err(unavailable(format!("redirect loop at {next}"), None));
120            }
121            visited.push(next.clone());
122            url = next;
123        }
124    }
125
126    /// The status list token at `uri`, fetched and verified.
127    async fn fetch(&self, uri: &str) -> Result<StatusListToken, StatusError> {
128        let invalid = |reason: &str, source: Option<BoxError>| StatusError::InvalidList {
129            uri: uri.to_owned(),
130            reason: reason.to_owned(),
131            source,
132        };
133        let response = self.get(uri).await?;
134        if !(200..300).contains(&response.status) {
135            return Err(StatusError::Unavailable {
136                uri: uri.to_owned(),
137                reason: format!("HTTP {}", response.status),
138                source: None,
139            });
140        }
141        let body = String::from_utf8(response.body)
142            .map_err(|e| invalid("the body is not UTF-8", Some(Box::new(e))))?;
143        let not_a_token = |e: BoxError| invalid("not a status list token", Some(e));
144        let jwt = Jwt::new(body.trim()).map_err(|e| not_a_token(Box::new(e)))?;
145        let (_, token) = StatusListToken::decode(&jwt).map_err(|e| not_a_token(Box::new(e)))?;
146        let jws = jwt.decode().map_err(|e| not_a_token(Box::new(e)))?;
147        let iss = token.additional.get("iss").and_then(|iss| iss.as_str());
148        let reference = KeyReference::from_jws(&jws, iss)
149            .map_err(|e| invalid("no key to verify it with", Some(Box::new(e))))?;
150        let key = self
151            .resolver
152            .resolve_key(&reference, KeyRole::Issuer)
153            .await
154            .map_err(|e| StatusError::UnresolvedKey {
155                uri: uri.to_owned(),
156                key: reference.to_string(),
157                source: e,
158            })?;
159        jws.verify(&self.verifier, &key)
160            .map_err(|e| invalid("the signature does not verify", Some(Box::new(e))))?;
161        let bound = match iss {
162            Some(iss) => reference.binds_to(Some(iss), &key),
163            None => matches!(reference, KeyReference::DidUrl(_)),
164        };
165        if !bound {
166            return Err(invalid(
167                &format!("key {reference} does not belong to the issuer {iss:?}"),
168                None,
169            ));
170        }
171        Ok(token)
172    }
173}
174
175#[async_trait::async_trait]
176impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> StatusCheck for StatusListCheck<C, R, V> {
177    /// Checks the Token Status List reference of a `status` claim; a `credentialStatus`
178    /// entry, or a `status` claim without one, is [`StatusError::Unsupported`].
179    async fn check(
180        &self,
181        status: CredentialStatus<'_>,
182        now: DateTime<Utc>,
183    ) -> Result<(), StatusError> {
184        let CredentialStatus::StatusClaim(status) = status else {
185            return Err(StatusError::Unsupported(status.mechanism()));
186        };
187        let Some(reference) = &status.status_list else {
188            let names: Vec<&str> = status
189                .mechanisms()
190                .filter_map(|m| match m {
191                    StatusMechanism::Other { name, .. } => Some(name),
192                    _ => None,
193                })
194                .collect();
195            return Err(StatusError::Unsupported(names.join(", ")));
196        };
197        let uri = &reference.uri;
198        let token = self.fetch(uri).await?;
199        let status_type = token.status_of(reference, now).map_err(|e| match e {
200            StatusListError::Expired { expired_at, now } => StatusError::Expired {
201                uri: uri.clone(),
202                expired_at,
203                now,
204            },
205            StatusListError::IndexOutOfRange { idx, len } => StatusError::IndexOutOfRange {
206                uri: uri.clone(),
207                idx,
208                len,
209            },
210            other => StatusError::InvalidList {
211                uri: uri.clone(),
212                reason: "it does not cover the reference".to_owned(),
213                source: Some(Box::new(other)),
214            },
215        })?;
216        if status_type.is_valid() {
217            Ok(())
218        } else {
219            Err(StatusError::Invalid(status_type.to_string()))
220        }
221    }
222}
223
224impl<C: core::fmt::Debug, R: core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug
225    for StatusListCheck<C, R, V>
226{
227    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
228        f.debug_struct("StatusListCheck")
229            .field("client", &self.client)
230            .field("resolver", &self.resolver)
231            .field("verifier", &self.verifier)
232            .finish()
233    }
234}