bh_status_list/
status_list_token.rs

1// Copyright (C) 2020-2025  The Blockhouse Technology Limited (TBTL).
2//
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU Affero General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or (at your
6// option) any later version.
7//
8// This program is distributed in the hope that it will be useful, but
9// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
10// or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public
11// License for more details.
12//
13// You should have received a copy of the GNU Affero General Public License
14// along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
16use bh_jws_utils::{jwt, JwkPublic, JwtSigner, JwtVerifier, SigningAlgorithm};
17use iref::Uri;
18use serde::{Deserialize, Serialize};
19
20use crate::{
21    token,
22    utils::jwt::{sign_jwt_token, verify_jwt_token},
23    Error, Result, StatusList, UriBuf,
24};
25
26const STATUS_LIST_TOKEN_TYP: &str = "statuslist+jwt";
27
28/// The cryptographically signed Status List in the JWT format.
29///
30/// More can be read [here][1].
31///
32/// [1]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-status-list-03#name-status-list-token
33pub struct StatusListToken<S>(jwt::Token<StatusListTokenHeader, StatusListTokenClaims, S>);
34
35impl StatusListToken<token::Signed> {
36    /// Creates a new **SIGNED** `StatusListToken`.
37    ///
38    /// The arguments are as follows:
39    /// - `claims`: claims of the Status List Token,
40    /// - `kid`: an ID of the private key used to sign the token,
41    /// - `key`: an implementation of the algorithm used to sign the token with
42    ///   the specific private key.
43    ///
44    /// # Errors
45    ///
46    /// If the signature fails to compute, the [`Error::TokenSigningFailed`]
47    /// will be returned.
48    pub fn new(claims: StatusListTokenClaims, kid: String, key: &impl JwtSigner) -> Result<Self> {
49        let alg = key.algorithm();
50
51        let header = StatusListTokenHeader {
52            alg,
53            kid,
54            typ: STATUS_LIST_TOKEN_TYP.to_owned(),
55        };
56
57        let signed_token = sign_jwt_token(header, claims, key)?;
58
59        Ok(Self(signed_token))
60    }
61
62    /// Returns the [`StatusListToken`] token as a `&str`.
63    pub fn as_str(&self) -> &str {
64        self.0.as_str()
65    }
66}
67
68impl std::fmt::Display for StatusListToken<token::Signed> {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74impl From<StatusListToken<token::Verified>> for (StatusListTokenHeader, StatusListTokenClaims) {
75    fn from(token: StatusListToken<token::Verified>) -> Self {
76        token.0.into()
77    }
78}
79
80impl StatusListToken<token::Verified> {
81    /// Verifies a signed Status List Token.
82    ///
83    /// It verifies the signature of the JWT token and also its claims.
84    ///
85    /// The arguments are as follows:
86    /// - `token`: the String representation of the JWT token,
87    /// - `verifier`: the verifier of the token signature,
88    /// - `current_time`: the current time in seconds, elapsed since the UNIX
89    ///   epoch,
90    /// - `iss`: the `iss` claim from the Verifiable Credential itself,
91    /// - `sub`: the `uri` claim from the `status` claim of the Verifiable
92    ///   Credential.
93    ///
94    /// # Errors
95    ///
96    /// The function can result in the following errors:
97    /// - [`Error::TokenParsingFailed`] if it is unable to parse the JWT token,
98    /// - [`Error::TokenSignatureVerificationFailed`] if the signature of the
99    ///   JWT token is invalid,
100    /// - [`Error::InvalidTokenHeaderTyp`] if the `typ` claim in the header is
101    ///   not set to `statuslist+jwt`,
102    /// - [`Error::InvalidTokenIss`] if the `iss` claim is not equal to the
103    ///   provided `iss` value,
104    /// - [`Error::InvalidTokenSub`] if the `sub` claim is not equal to the
105    ///   provided `sub` value,
106    /// - [`Error::TokenExpired`] if the token is expired based on the `exp`
107    ///   claim.
108    pub fn verify(
109        token: &str,
110        verifier: &(impl JwtVerifier + ?Sized),
111        public_key: &JwkPublic,
112        current_time: u64,
113        iss: &Uri,
114        sub: &Uri,
115    ) -> Result<Self> {
116        // Verify the JWT signature.
117        let verified_token = verify_jwt_token(token, verifier, public_key)?;
118
119        // Verify JWT header.
120        verified_token.header().verify()?;
121
122        // Verify JWT claims.
123        verified_token.claims().verify(current_time, iss, sub)?;
124
125        Ok(Self(verified_token))
126    }
127
128    /// Returns the token header.
129    pub fn header(&self) -> &StatusListTokenHeader {
130        self.0.header()
131    }
132
133    /// Returns the token claims.
134    pub fn claims(&self) -> &StatusListTokenClaims {
135        self.0.claims()
136    }
137}
138
139/// Header of the Status List Token in the JWT format.
140#[derive(Serialize, Deserialize)]
141#[non_exhaustive]
142pub struct StatusListTokenHeader {
143    /// An algorithm used to sign the token.
144    pub alg: SigningAlgorithm,
145
146    /// An ID of the private key used to sign the token.
147    pub kid: String,
148
149    /// Type of the JWT, which is always _`statuslist+jwt`_.
150    pub typ: String,
151}
152
153impl StatusListTokenHeader {
154    /// Verifies the header of the Status List Token.
155    ///
156    /// The only step is checking if the `typ` claim is set to `statuslist+jwt`.
157    /// If not, [`Error::InvalidTokenHeaderTyp`] is returned.
158    fn verify(&self) -> Result<()> {
159        if self.typ != STATUS_LIST_TOKEN_TYP {
160            return Err(bherror::Error::root(Error::InvalidTokenHeaderTyp(
161                self.typ.to_owned(),
162            )));
163        }
164
165        Ok(())
166    }
167}
168
169impl jwt::JoseHeader for StatusListTokenHeader {
170    fn algorithm_type(&self) -> jwt::AlgorithmType {
171        self.alg.into()
172    }
173}
174
175/// Claims of the Status List Token in the JWT format.
176#[derive(Serialize, Deserialize)]
177#[non_exhaustive]
178pub struct StatusListTokenClaims {
179    /// A unique identifier of the Issuer of the Status List Token.
180    ///
181    /// It **MUST** be the same as the `iss` claim value of the Verifiable
182    /// Credential itself.
183    pub iss: UriBuf,
184
185    /// The URI of the Status List Token.
186    ///
187    /// It **MUST** be equal to the `uri` claim in the `status` claim of the
188    /// Verifiable Credential.
189    pub sub: UriBuf,
190
191    /// The time at which the Status List Token was issued.
192    ///
193    /// It is expressed in seconds since the *UNIX* epoch.
194    pub iat: u64,
195
196    /// The optional expiration time of the Status List Token, after which the
197    /// token is not valid anymore.
198    ///
199    /// It is expressed in seconds since the *UNIX* epoch.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub exp: Option<u64>,
202
203    /// The optional *time-to-live* parameter, specifying the maximum amount of
204    /// time, in seconds, that the Status List Token can be cached before a
205    /// fresh copy should be retrieved.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub ttl: Option<u64>,
208
209    /// The Status List contained in the token.
210    pub status_list: StatusList,
211}
212
213impl StatusListTokenClaims {
214    /// Sets claims for the Status List Token.
215    ///
216    /// The arguments are as follows:
217    /// - `iss`: the unique identifier of the Issuer of the Status List Token,
218    /// - `sub`: the URI of the Status List Token,
219    /// - `iat`: the time at which the Status List Token was issued,
220    /// - `exp`: the expiration time of the Status List Token, after which the
221    ///   token is not valid anymore,
222    /// - `ttl`: the *time-to-live* parameter, specifying the maximum amount of
223    ///   time, in seconds, that the Status List Token can be cached before a
224    ///   fresh copy should be retrieved,
225    /// - `status_list`: the Status List contained in the token.
226    ///
227    /// # Note
228    ///
229    /// The `iss` value **MUST** be the same as the `iss` claim value of the
230    /// Verifiable Credentials whose `status` values are stored in the list.
231    ///
232    /// The `sub` value **MUST** be equal to the `uri` claim in the `status`
233    /// claim of the Verifiable Credentials whose `status` values are stored in
234    /// the list.
235    pub fn new(
236        iss: UriBuf,
237        sub: UriBuf,
238        iat: u64,
239        exp: Option<u64>,
240        ttl: Option<u64>,
241        status_list: StatusList,
242    ) -> Self {
243        Self {
244            iss,
245            sub,
246            iat,
247            exp,
248            ttl,
249            status_list,
250        }
251    }
252
253    /// Verifies claims of the Status List Token.
254    ///
255    /// The following verification steps are performed:
256    /// - `iss`: checks if the `iss` claim is equal to the provided `iss` value,
257    ///   and returns [`Error::InvalidTokenIss`] if not,
258    /// - `sub`: checks if the `sub` claim is equal to the provided `sub` value,
259    ///   and returns [`Error::InvalidTokenSub`] if not,
260    /// - `exp`: checks whether `exp` is in the past (token expired), and
261    ///   returns [`Error::TokenExpired`] if it is.
262    fn verify(&self, current_time: u64, iss: &Uri, sub: &Uri) -> Result<()> {
263        if self.iss != iss {
264            return Err(bherror::Error::root(Error::InvalidTokenIss(
265                iss.to_owned(),
266                self.iss.clone(),
267            )));
268        }
269
270        if self.sub != sub {
271            return Err(bherror::Error::root(Error::InvalidTokenSub(
272                sub.to_owned(),
273                self.sub.clone(),
274            )));
275        }
276
277        if let Some(exp) = self.exp {
278            if exp < current_time {
279                return Err(bherror::Error::root(Error::TokenExpired(current_time, exp)));
280            }
281        }
282
283        Ok(())
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use std::str::FromStr as _;
290
291    use bh_jws_utils::{
292        json_object, BoxError,
293        SigningAlgorithm::{self, *},
294    };
295
296    use super::*;
297    use crate::{JwtError, StatusBits, StatusListInternal};
298
299    fn str_to_uri(s: &str) -> UriBuf {
300        UriBuf::from_str(s).unwrap()
301    }
302
303    struct DummySigner(SigningAlgorithm, bool);
304
305    impl bh_jws_utils::Signer for DummySigner {
306        fn algorithm(&self) -> SigningAlgorithm {
307            self.0
308        }
309
310        fn sign(&self, _message: &[u8]) -> std::result::Result<Vec<u8>, BoxError> {
311            self.1
312                .then(|| b"signatureb64".to_vec())
313                .ok_or(Box::new(JwtError::InvalidSignature))
314        }
315
316        fn public_jwk(&self) -> std::result::Result<JwkPublic, BoxError> {
317            unimplemented!("this is currently not needed")
318        }
319    }
320
321    struct DummyVerifier(SigningAlgorithm, std::result::Result<bool, ()>);
322
323    impl bh_jws_utils::SignatureVerifier for DummyVerifier {
324        fn algorithm(&self) -> SigningAlgorithm {
325            self.0
326        }
327
328        fn verify(&self, _: &[u8], _: &[u8], _: &JwkPublic) -> std::result::Result<bool, BoxError> {
329            self.1.map_err(|_| Box::new(JwtError::Format) as _)
330        }
331    }
332
333    fn dummy_jwk() -> JwkPublic {
334        json_object!({})
335    }
336
337    fn get_valid_jwt(alg: SigningAlgorithm, iss: UriBuf, sub: UriBuf, exp: Option<u64>) -> String {
338        let status_list = StatusListInternal::new(StatusBits::Eight, Option::None);
339
340        let claims =
341            StatusListTokenClaims::new(iss, sub, 100, exp, Option::None, status_list.into());
342
343        let token_signed =
344            StatusListToken::new(claims, "kid".to_owned(), &DummySigner(alg, true)).unwrap();
345
346        token_signed.to_string()
347    }
348
349    #[test]
350    fn test_status_list_token_signing_fails() {
351        let status_list = StatusListInternal::new(StatusBits::Eight, Option::None);
352
353        let claims = StatusListTokenClaims::new(
354            str_to_uri("http://iss"),
355            str_to_uri("http://sub"),
356            100,
357            Option::None,
358            Option::None,
359            status_list.into(),
360        );
361
362        let err = StatusListToken::new(claims, "kid".to_owned(), &DummySigner(Es512, false))
363            .err()
364            .unwrap();
365
366        assert!(matches!(err.error, Error::TokenSigningFailed));
367    }
368
369    #[test]
370    fn test_status_list_token_new_success() {
371        let status_list = StatusListInternal::new(StatusBits::Two, Option::None);
372
373        let claims = StatusListTokenClaims::new(
374            str_to_uri("http://iss"),
375            str_to_uri("http://sub"),
376            100,
377            Option::None,
378            Option::None,
379            status_list.into(),
380        );
381
382        let _token =
383            StatusListToken::new(claims, "kid".to_owned(), &DummySigner(Es256, true)).unwrap();
384    }
385
386    #[test]
387    fn test_status_list_token_verify_success() {
388        let status_list = StatusListInternal::new(StatusBits::One, Option::None);
389
390        let iss = str_to_uri("http://iss");
391        let sub = str_to_uri("http://sub");
392        let iat = 100;
393
394        let claims = StatusListTokenClaims::new(
395            iss.clone(),
396            sub.clone(),
397            iat,
398            Option::None,
399            Option::None,
400            status_list.into(),
401        );
402
403        let token_signed =
404            StatusListToken::new(claims, "kid".to_owned(), &DummySigner(Es512, true)).unwrap();
405
406        let token_verified = StatusListToken::verify(
407            token_signed.as_str(),
408            &DummyVerifier(Es512, Ok(true)),
409            &dummy_jwk(),
410            100,
411            &iss,
412            &sub,
413        )
414        .unwrap();
415
416        let (header, claims) = token_verified.into();
417
418        assert_eq!(header.alg, Es512);
419        assert_eq!(header.kid, "kid");
420        assert_eq!(header.typ, STATUS_LIST_TOKEN_TYP);
421
422        assert_eq!(claims.iss, iss);
423        assert_eq!(claims.sub, sub);
424        assert_eq!(claims.iat, iat);
425    }
426
427    #[test]
428    fn test_status_list_token_verify_parse_fails() {
429        let err = StatusListToken::verify(
430            "invalid-token",
431            &DummyVerifier(Ps384, Ok(true)),
432            &dummy_jwk(),
433            100,
434            &str_to_uri("http://iss"),
435            &str_to_uri("http://sub"),
436        )
437        .err()
438        .unwrap();
439
440        assert!(matches!(err.error, Error::TokenParsingFailed));
441    }
442
443    #[test]
444    fn test_status_list_token_verify_invalid_alg_fails() {
445        let iss = str_to_uri("http://iss");
446        let sub = str_to_uri("http://sub");
447
448        let jwt = get_valid_jwt(Ps512, iss.clone(), sub.clone(), Option::None);
449
450        let err = StatusListToken::verify(
451            &jwt,
452            &DummyVerifier(Ps256, Ok(true)),
453            &dummy_jwk(),
454            100,
455            &iss,
456            &sub,
457        )
458        .err()
459        .unwrap();
460
461        assert!(matches!(err.error, Error::TokenSignatureVerificationFailed));
462    }
463
464    #[test]
465    fn test_status_list_token_verify_signature_mismatch_fails() {
466        let iss = str_to_uri("http://iss");
467        let sub = str_to_uri("http://sub");
468
469        let jwt = get_valid_jwt(Ps384, iss.clone(), sub.clone(), Option::None);
470
471        let err = StatusListToken::verify(
472            &jwt,
473            &DummyVerifier(Ps384, Ok(false)),
474            &dummy_jwk(),
475            100,
476            &iss,
477            &sub,
478        )
479        .err()
480        .unwrap();
481
482        assert!(matches!(err.error, Error::TokenSignatureVerificationFailed));
483    }
484
485    #[test]
486    fn test_status_list_token_verify_signature_verification_fails() {
487        let iss = str_to_uri("http://iss");
488        let sub = str_to_uri("http://sub");
489
490        let jwt = get_valid_jwt(Ps384, iss.clone(), sub.clone(), Option::None);
491
492        let err = StatusListToken::verify(
493            &jwt,
494            &DummyVerifier(Ps384, Err(())),
495            &dummy_jwk(),
496            100,
497            &iss,
498            &sub,
499        )
500        .err()
501        .unwrap();
502
503        assert!(matches!(err.error, Error::TokenSignatureVerificationFailed));
504    }
505
506    #[test]
507    fn test_status_list_token_verify_iss_mismatch_fails() {
508        let iss = str_to_uri("http://iss");
509        let sub = str_to_uri("http://sub");
510
511        let iss_invalid = str_to_uri("http://iss-invalid");
512
513        let jwt = get_valid_jwt(Es512, iss_invalid.clone(), sub.clone(), Option::None);
514
515        let err = StatusListToken::verify(
516            &jwt,
517            &DummyVerifier(Es512, Ok(true)),
518            &dummy_jwk(),
519            100,
520            &iss,
521            &sub,
522        )
523        .err()
524        .unwrap();
525
526        assert!(
527            matches!(err.error, Error::InvalidTokenIss(iss_vc, iss_rec) if iss_vc == iss && iss_rec == iss_invalid)
528        );
529    }
530
531    #[test]
532    fn test_status_list_token_verify_sub_mismatch_fails() {
533        let iss = str_to_uri("http://iss");
534        let sub = str_to_uri("http://sub");
535
536        let sub_invalid = str_to_uri("http://sub-invalid");
537
538        let jwt = get_valid_jwt(Es256, iss.clone(), sub_invalid.clone(), Option::None);
539
540        let err = StatusListToken::verify(
541            &jwt,
542            &DummyVerifier(Es256, Ok(true)),
543            &dummy_jwk(),
544            100,
545            &iss,
546            &sub,
547        )
548        .err()
549        .unwrap();
550
551        assert!(
552            matches!(err.error, Error::InvalidTokenSub(sub_vc, sub_rec) if sub_vc == sub && sub_rec == sub_invalid)
553        );
554    }
555
556    #[test]
557    fn test_status_list_token_verify_token_expired_fails() {
558        let iss = str_to_uri("http://iss");
559        let sub = str_to_uri("http://sub");
560
561        let jwt = get_valid_jwt(Es256, iss.clone(), sub.clone(), Some(200));
562
563        let err = StatusListToken::verify(
564            &jwt,
565            &DummyVerifier(Es256, Ok(true)),
566            &dummy_jwk(),
567            300,
568            &iss,
569            &sub,
570        )
571        .err()
572        .unwrap();
573
574        assert!(matches!(err.error, Error::TokenExpired(curr, exp) if curr == 300 && exp == 200));
575    }
576}