diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 5f96681..2f2b6fd 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -154,6 +154,9 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo install cargo-hack --locked - run: cargo hack clippy --each-feature --locked -- -D warnings + # The Token Status List without SD-JWT: the `status` claim of a JWT credential reaches the + # `StatusCheck` with `vc` and `status-list` alone. + - run: cargo test --no-default-features --features vc,status-list,did-key,did-jwk --locked --test vc_validation # The crate packages and builds from the package, with no git or path dependencies. publish: diff --git a/AGENTS.md b/AGENTS.md index 822666d..3b88021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ ## Ground rules -- One crate, two layers. `did`, `jose`, `keys`, `identity`, `resolver` and `methods` never import `vc`, `sd_jwt_vc`, `domain_linkage` or `linked_vp`. CI enforces it; keep it green. +- One crate, two layers. `did`, `jose`, `keys`, `identity`, `resolver` and `methods` never import `vc`, `sd_jwt_vc`, `status_list`, `domain_linkage` or `linked_vp`. CI enforces it; keep it green. - Extension points are traits (`DidResolver`, `DidProducer`, `KeyStore`). Never add a closed enum of methods or backends to the library. - JWT/JWS and SD-JWT only. No JSON-LD processing. - Tests are specification vectors (W3C, RFCs, DIF, method specs), never another library's output. diff --git a/Cargo.toml b/Cargo.toml index 98e2e52..d0afd7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["secp256k1", "software", "did-key", "did-jwk", "did-web", "reqwest", "vc", "sd-jwt", "domain-linkage", "linked-vp"] +default = ["secp256k1", "software", "did-key", "did-jwk", "did-web", "reqwest", "vc", "status-list", "sd-jwt", "domain-linkage", "linked-vp"] # ES256K signatures and `secp256k1` keys, via `k256`. secp256k1 = ["dep:k256"] # RS256 and PS256 signatures, via `rsa`; keys of at least 2048 bits (RFC 7518 §3.3, §3.5). @@ -47,8 +47,11 @@ reqwest = ["did-web", "dep:reqwest", "dep:rustls", "dep:tokio"] local-network = ["reqwest"] # Verifiable credentials and presentations as JWTs (`vc`), via `chrono` for timestamps. vc = ["dep:chrono"] -# SD-JWT (RFC 9901) and SD-JWT VC (`sd_jwt_vc`): `getrandom` for salts, `miniz_oxide` for status lists. -sd-jwt = ["vc", "dep:getrandom", "dep:miniz_oxide"] +# The IETF Token Status List (`status_list`) for JWT and SD-JWT credentials alike: `miniz_oxide` +# for the compressed list. +status-list = ["vc", "dep:miniz_oxide"] +# SD-JWT (RFC 9901) and SD-JWT VC (`sd_jwt_vc`), with the Token Status List: `getrandom` for salts. +sd-jwt = ["vc", "status-list", "dep:getrandom"] # DIF Well Known DID Configuration (`domain_linkage`), over the `vc` module and `did:web`'s `HttpClient`. domain-linkage = ["vc", "did-web"] # DIF Linked Verifiable Presentation (`linked_vp`), over the `vc` module and `did:web`'s `HttpClient`. diff --git a/README.md b/README.md index c32c9a8..3cce591 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Add it to a project with `cargo add identity-kit`. The API reference is on [docs - ✅ `KeyReference` and `KeyResolver`: a token's key found from a DID URL `kid`, a `#fragment` relative to a DID `iss`, a `jwk` header, `cnf`, or the JWT VC issuer metadata of an HTTPS `iss` - ✅ `did:key`, `did:jwk` and `did:web` methods, with a hardened HTTPS client for `did:web` - ✅ Verifiable credentials and presentations as JWTs: the [VCDM 1.1](https://www.w3.org/TR/vc-data-model/) JWT profile, [VCDM 2.0](https://www.w3.org/TR/vc-data-model-2.0/) with [VC-JOSE-COSE](https://www.w3.org/TR/vc-jose-cose/) envelopes, and validators over any DID method -- ✅ SD-JWT (RFC 9901), SD-JWT VC and VCDM 2.0 credentials secured with SD-JWT (`vc+sd-jwt`): issuing with selective disclosure, presenting with key binding, validating over any DID method, the Token Status List and type metadata +- ✅ SD-JWT (RFC 9901), SD-JWT VC and VCDM 2.0 credentials secured with SD-JWT (`vc+sd-jwt`): issuing with selective disclosure, presenting with key binding, validating over any DID method, and type metadata +- ✅ The IETF Token Status List for JWT and SD-JWT credentials alike: issuing and signing status lists, and checking the `status` claim - ✅ DIF Well Known DID Configuration: the DID configuration resource, its domain linkage credentials and their validation against an origin - ✅ DIF Linked Verifiable Presentation: `LinkedVerifiablePresentation` services, presentations signed for publishing, and fetching and validating what a DID document links @@ -49,7 +50,8 @@ All features are additive; the lower layer (`did`, `jose`, `keys`, `identity`, ` | `in-memory` | | `InMemoryKeyStore`: keys in memory, optionally derived from a seed | | `pkcs11` | | `Pkcs11KeyStore`: keys in a PKCS#11 module (an HSM, SoftHSM2), via `cryptoki` and `tokio`'s blocking pool | | `vc` | ✅ | Verifiable credentials and presentations as JWTs (`vc`); pulls in `chrono` | -| `sd-jwt` | ✅ | SD-JWT, SD-JWT VC and `vc+sd-jwt` credentials (`sd_jwt_vc`); implies `vc`, pulls in `miniz_oxide` | +| `status-list` | ✅ | The IETF Token Status List for JWT and SD-JWT credentials (`status_list`); implies `vc`, pulls in `miniz_oxide` | +| `sd-jwt` | ✅ | SD-JWT, SD-JWT VC and `vc+sd-jwt` credentials (`sd_jwt_vc`); implies `vc` and `status-list` | | `domain-linkage` | ✅ | DIF Well Known DID Configuration (`domain_linkage`); implies `vc` and `did-web` | | `linked-vp` | ✅ | DIF Linked Verifiable Presentation (`linked_vp`); implies `vc` and `did-web` | | `jwks` | | `resolver::JwksResolver`: the keys of an HTTPS issuer from its JWT VC issuer metadata; implies `did-web` | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 21c7ccd..ceb9af3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -11,8 +11,8 @@ implemented; [`FEATURES.md`](FEATURES.md) tracks the status of each feature and ## Principles 1. **One crate, two layers, hard boundary.** Lower layer: `did`, `jose`, `keys`, `identity`, - `resolver`, `methods`. Upper layer: `vc`, `sd_jwt_vc`, `domain_linkage`, `linked_vp`. Lower modules - never import upper ones. The upper modules are feature-gated so CI can prove the boundary + `resolver`, `methods`. Upper layer: `vc`, `sd_jwt_vc`, `status_list`, `domain_linkage`, + `linked_vp`. Lower modules never import upper ones. The upper modules are feature-gated so CI can prove the boundary (see Conformance). 2. **Traits, not enums, for extension points.** A DID method implements `DidResolver` and/or `DidProducer` and declares `MethodCapabilities`. A key backend implements `KeyStore` and declares @@ -56,7 +56,8 @@ identity-kit/ │ ├── resolver/ # DidResolver, DidProducer, MethodCapabilities, Resolver, DocumentCache, dereferencing, KeyReference, KeyResolver │ ├── methods/ # key, jwk, web │ ├── vc/ # [vc] Jwt, Credential, Presentation, CredentialV2, EnvelopedVc, ClaimPath, validation -│ ├── sd_jwt_vc/ # [sd-jwt] SD-JWT (RFC 9901), SD-JWT VC, status list, type metadata, validator +│ ├── sd_jwt_vc/ # [sd-jwt] SD-JWT (RFC 9901), SD-JWT VC, type metadata, validator +│ ├── status_list/ # [status-list] Token Status List: status claim, list, token, StatusListCheck │ ├── domain_linkage/ # [domain-linkage] DID configuration resource builder and validator │ └── linked_vp/ # [linked-vp] linked presentation services, builder, fetcher and validator ├── xtask/ # publish = false: release checks, vector regeneration, key CLI @@ -79,7 +80,8 @@ All additive. CI runs `cargo hack --each-feature` and the no-default-features bu | `software` | on | `zeroize`; JWK-file key store | | `pkcs11` | off | `cryptoki`; `tokio` (`rt`) for the blocking pool; PKCS#11 key store | | `vc` | on | `chrono` (`now`, `serde`, `std` only); W3C VC/VP JWT profile and validators | -| `sd-jwt` | on | `getrandom` (salts) and `miniz_oxide` (status lists); implies `vc` | +| `status-list` | on | `miniz_oxide` (the compressed list); the Token Status List for JWT and SD-JWT credentials; implies `vc` | +| `sd-jwt` | on | `getrandom` (salts); implies `vc` and `status-list` | | `domain-linkage` | on | implies `vc` and `did-web` (for `HttpClient`) | | `linked-vp` | on | implies `vc` and `did-web` (for `HttpClient`) | | `jwks` | off | implies `did-web` (for `HttpClient`); `JwksResolver` over JWT VC issuer metadata | @@ -321,8 +323,8 @@ pub struct JwtValidationOptions { algorithms, typ, issuer, audience, role, issue `assertionMethod` or `authentication`), `TimeOptions` (explicit `now`, leeway), issuer and holder binding to the party the `KeyReference` names, audience and nonce, accepted `typ` beyond the profile's, `SubjectHolderRelationship`, `FailFast`, one format-agnostic `StatusCheck` trait over - `CredentialStatus::Vcdm` (and `CredentialStatus::SdJwt` with `sd-jwt`, for the `status` claim - of SD-JWT and JWT credentials alike, Token Status List §6.1) with a `StatusPolicy`, + `CredentialStatus::Vcdm` (and `CredentialStatus::StatusClaim` with `status-list`, for the + `status` claim of SD-JWT and JWT credentials alike, Token Status List §6.1) with a `StatusPolicy`, `extract_issuer`/`extract_holder` (ADR 0004, ADR 0006); `ClaimPath` (SD-JWT VC §5.6.1, OID4VP §7 claims path pointer), re-exported from `sd_jwt_vc`. - `sd_jwt_vc` (`sd-jwt`): RFC 9901 implemented in the crate (ADR 0005): `Disclosure`, @@ -333,21 +335,26 @@ pub struct JwtValidationOptions { algorithms, typ, issuer, audience, role, issue enforced, `SdJwtVcBuilder`; `SdJwtCredentialV2` for a VCDM 2.0 credential secured with SD-JWT (VC-JOSE-COSE §3.2.1: `typ: vc+sd-jwt`, the `CredentialV2` as the payload, `cnf` and `status` beside it), `SdJwtCredentialV2Builder`, and `EnvelopedVc::to_sd_jwt` for the - `application/vc+sd-jwt` envelope of a VCDM 2.0 presentation; `Status`/`StatusListRef`/ - `StatusMechanism`, `StatusList` (ZLIB bit array) and `StatusListToken` for the Token Status - List; `TypeMetadata` with claim paths, `extends` folding, claim validation and `Integrity` + `application/vc+sd-jwt` envelope of a VCDM 2.0 presentation; `TypeMetadata` with claim paths, `extends` folding, claim validation and `Integrity` metadata; `SdJwtCredentialValidator` over `KeyResolver` + `JwsVerifier` (`validate` an SD-JWT VC, `validate_v2` a VCDM 2.0 credential: issuer key from the token's `KeyReference` under `assertionMethod`, `iss` or `issuer` bound to the party it names, time claims and validity period, key binding with `KeyBindingOptions` and the holder key from `cnf` (`jwk` or `kid`) under `authentication`, the `vc` `StatusCheck` with a `StatusPolicy` for `status` and - `credentialStatus`, `StatusListCheck` over an `HttpClient`: `Accept: - application/statuslist+jwt`, and at most three redirects, each target `https` and fetched - through the client again so that every hop passes its address checks; its `StatusError` tells - a list that could not be fetched from one that is invalid, signed by a key that does not - resolve, expired or too short, so that a caller can retry only the first). - `JwtPresentationValidator::validate_v2` unwraps `vc+sd-jwt` envelopes next to `vc+jwt` ones - (`PresentedCredentialV2`), with key binding required per `PresentationValidationOptions`. + `credentialStatus`). `JwtPresentationValidator::validate_v2` unwraps `vc+sd-jwt` envelopes + next to `vc+jwt` ones (`PresentedCredentialV2`), with key binding required per + `PresentationValidationOptions`. +- `status_list` (`status-list`): the IETF Token Status List, which the draft defines for any JWT + (§6.1), so it serves JWT and SD-JWT credentials alike and needs no SD-JWT code: + `Status`/`StatusListRef`/`StatusMechanism` for the `status` claim, `StatusType`, `StatusList` + (ZLIB bit array) and `StatusListToken` (signed with any `Signer`, read with `status_of`), and, + with `did-web` as well, `StatusListCheck`, the `vc` `StatusCheck` over an `HttpClient`: + `Accept: application/statuslist+jwt`, and at most three redirects, each target `https` and + fetched through the client again so that every hop passes its address checks; its + `StatusError` tells a list that could not be fetched from one that is invalid, signed by a key + that does not resolve, expired or too short, so that a caller can retry only the first. It + depends on `vc` for `Jwt` and `JwtClaims`. `sd_jwt_vc` keeps deprecated aliases of its types, + and `sd_jwt_vc::validation::StatusListCheck`, for one release. - `domain_linkage` (`domain-linkage`): `DomainLinkageConfiguration` (JWT entries typed, Linked Data Proof entries carried as data), `DomainLinkageCredentialBuilder`, `DomainLinkageValidator` implementing the DIF Well Known DID Configuration checks (`iss == sub == diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 659298c..336d26b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -92,8 +92,8 @@ additive; see [`DESIGN.md`](DESIGN.md#features) for what each one pulls in. | `JwtCredentialValidator`, `JwtPresentationValidator`: issuer and holder binding, clock-skew leeway, `FailFast`, `StatusCheck` hook | ✅ | `vc` | [VCDM 1.1][vcdm11], [VCDM 2.0][vcdm20] | [#11](https://github.com/impierce/identity-kit/issues/11) | | `JwtPresentationValidator::validate_v2`, `decode_presentation_v2`: VCDM 2.0 `vp+jwt` presentations of enveloped `vc+jwt` credentials | ✅ | `vc` | [VCDM 2.0][vcdm20], [VC-JOSE-COSE][vc-jose-cose] | [#36](https://github.com/impierce/identity-kit/issues/36) | | Additional accepted header `typ` values per validation (`additional_typ`) | ✅ | `vc` | [VCDM 1.1][vcdm11], [VC-JOSE-COSE][vc-jose-cose] | [#34](https://github.com/impierce/identity-kit/issues/34) | -| One format-agnostic `StatusCheck` over `CredentialStatus::{Vcdm, SdJwt}`, replacing `SdJwtStatusCheck` | ✅ | `vc` | [VCDM 1.1][vcdm11], [Token Status List][status-list] | [#55](https://github.com/impierce/identity-kit/issues/55) | -| `status` claim of JWT credentials (Token Status List §6.1) checked beside `credentialStatus`, as `CredentialStatus::SdJwt` | ✅ | `vc`, `sd-jwt` | [VCDM 1.1][vcdm11], [VC-JOSE-COSE][vc-jose-cose], [Token Status List][status-list] | [#93](https://github.com/impierce/identity-kit/issues/93) | +| One format-agnostic `StatusCheck` over `CredentialStatus::{Vcdm, StatusClaim}`, replacing `SdJwtStatusCheck` | ✅ | `vc` | [VCDM 1.1][vcdm11], [Token Status List][status-list] | [#55](https://github.com/impierce/identity-kit/issues/55) | +| `status` claim of JWT credentials (Token Status List §6.1) checked beside `credentialStatus`, as `CredentialStatus::StatusClaim` | ✅ | `vc`, `status-list` | [VCDM 1.1][vcdm11], [VC-JOSE-COSE][vc-jose-cose], [Token Status List][status-list] | [#93](https://github.com/impierce/identity-kit/issues/93) | | `ClaimPath` moved out of the `sd-jwt` feature, `utoipa` schema | ✅ | `vc`, `utoipa` | [SD-JWT VC §5.6.1][sd-jwt-vc], [OID4VP §7][oid4vp] | [#56](https://github.com/impierce/identity-kit/issues/56) | ### `sd_jwt_vc`: SD-JWT verifiable credentials @@ -101,15 +101,21 @@ additive; see [`DESIGN.md`](DESIGN.md#features) for what each one pulls in. | Feature | Status | Cargo feature | Specification | Issue | | -------------------------------------------------------------------- | :----: | ------------- | ---------------------------------------------- | --------------------------------------------------------- | | Builder with first-class `jti`, parser, `SdJwtCredentialValidator` | ✅ | `sd-jwt` | [RFC 9901][rfc9901], [SD-JWT VC][sd-jwt-vc] | [#12](https://github.com/impierce/identity-kit/issues/12) | -| `Status`, `StatusListRef`, `StatusMechanism` | ✅ | `sd-jwt` | [Token Status List][status-list] | [#12](https://github.com/impierce/identity-kit/issues/12) | -| `StatusType` serializes as its registered name or its number, `FromStr`, `utoipa` schema | ✅ | `sd-jwt`, `utoipa` | [Token Status List][status-list] | [#101](https://github.com/impierce/identity-kit/issues/101) | | Type metadata | ✅ | `sd-jwt` | [SD-JWT VC §6][sd-jwt-vc] | [#12](https://github.com/impierce/identity-kit/issues/12) | | `SdJwtCredentialV2`: a VCDM 2.0 credential secured with SD-JWT (`vc+sd-jwt`, `application/vc+sd-jwt`), told apart from an SD-JWT VC by `typ` and payload | ✅ | `sd-jwt` | [VC-JOSE-COSE §3.2.1][vc-jose-cose] | [#49](https://github.com/impierce/identity-kit/issues/49) | | `SdJwtCredentialV2Builder`: conceal credential properties, decoys, `cnf`, `status`, `iat`/`exp` beside `validFrom`/`validUntil` | ✅ | `sd-jwt` | [VC-JOSE-COSE §3.2.1][vc-jose-cose], [RFC 9901][rfc9901] | [#49](https://github.com/impierce/identity-kit/issues/49) | | `SdJwtCredentialValidator::validate_v2`: issuer binding, validity period, key binding, `credentialStatus` and `status` | ✅ | `sd-jwt` | [VC-JOSE-COSE §3.2.1][vc-jose-cose], [RFC 9901 §7][rfc9901] | [#49](https://github.com/impierce/identity-kit/issues/49) | | `JwtPresentationValidator::validate_v2` and `decode_presentation_v2` unwrap `application/vc+sd-jwt` envelopes (`EnvelopedVc::to_sd_jwt`, `PresentedCredentialV2`), key binding required per `PresentationValidationOptions::key_binding` | ✅ | `sd-jwt` | [VC-JOSE-COSE §3.2.2][vc-jose-cose] | [#49](https://github.com/impierce/identity-kit/issues/49) | | Key binding with the holder key from `cnf.kid` (resolved through `KeyReference`) | ✅ | `sd-jwt` | [RFC 7800 §3.4][rfc7800] | [#53](https://github.com/impierce/identity-kit/issues/53) | -| `StatusListCheck` asks for `application/statuslist+jwt`, reads gzip-encoded tokens and follows up to three redirects, each through the `HttpClient` (`HttpClient::get_accepting`, `HttpResponse::location`) | ✅ | `sd-jwt`, `reqwest` | [Token Status List][status-list] | [#102](https://github.com/impierce/identity-kit/issues/102) | + +### `status_list`: Token Status List + +| Feature | Status | Cargo feature | Specification | Issue | +| -------------------------------------------------------------------- | :----: | ------------- | ---------------------------------------------- | --------------------------------------------------------- | +| `Status`, `StatusListRef`, `StatusMechanism` | ✅ | `status-list` | [Token Status List][status-list] | [#12](https://github.com/impierce/identity-kit/issues/12) | +| `StatusType` serializes as its registered name or its number, `FromStr`, `utoipa` schema | ✅ | `status-list`, `utoipa` | [Token Status List][status-list] | [#101](https://github.com/impierce/identity-kit/issues/101) | +| `StatusListCheck` asks for `application/statuslist+jwt`, reads gzip-encoded tokens and follows up to three redirects, each through the `HttpClient` (`HttpClient::get_accepting`, `HttpResponse::location`) | ✅ | `status-list`, `reqwest` | [Token Status List][status-list] | [#102](https://github.com/impierce/identity-kit/issues/102) | +| The Token Status List in its own module and feature, out of `sd_jwt_vc`: a `vc` build without `sd-jwt` checks the `status` claim of JWT credentials; deprecated aliases in `sd_jwt_vc` for one release | ✅ | `status-list` | [Token Status List][status-list] | [#132](https://github.com/impierce/identity-kit/issues/132) | ### `domain_linkage`: DID configuration @@ -177,7 +183,7 @@ Decided in [`DESIGN.md`](DESIGN.md#principles); each would need an ADR to revisi | [RFC 8812: secp256k1 in JOSE][rfc8812] | IETF | `jose` (`secp256k1`) | | [RFC 9901: SD-JWT][rfc9901] | IETF | `sd_jwt_vc` | | [SD-JWT VC][sd-jwt-vc] | IETF draft | `sd_jwt_vc` | -| [Token Status List][status-list] | IETF draft | `sd_jwt_vc` | +| [Token Status List][status-list] | IETF draft | `status_list` | | [PKCS #11 v3.1][pkcs11] | OASIS | `keys::pkcs11` | | [multicodec][multicodec], [multibase][multibase] | Multiformats | `methods::key` | diff --git a/fuzz/fuzz_targets/sd_jwt_vc_metadata.rs b/fuzz/fuzz_targets/sd_jwt_vc_metadata.rs index 4807772..4fa73c3 100644 --- a/fuzz/fuzz_targets/sd_jwt_vc_metadata.rs +++ b/fuzz/fuzz_targets/sd_jwt_vc_metadata.rs @@ -6,9 +6,8 @@ use std::str::FromStr as _; use identity_kit::domain_linkage::DomainLinkageConfiguration; -use identity_kit::sd_jwt_vc::{ - Integrity, IntegrityAlgorithm, StatusList, StatusListToken, TypeMetadata, -}; +use identity_kit::sd_jwt_vc::{Integrity, IntegrityAlgorithm, TypeMetadata}; +use identity_kit::status_list::{StatusList, StatusListToken}; use identity_kit::vc::{ClaimPath, Jwt, PathComponent}; use libfuzzer_sys::fuzz_target; use serde_json::Value; diff --git a/src/jose/mod.rs b/src/jose/mod.rs index dda14f9..40c5c8d 100644 --- a/src/jose/mod.rs +++ b/src/jose/mod.rs @@ -46,7 +46,7 @@ mod verifier; pub use algorithm::{JwsAlgorithm, UnknownAlgorithm}; pub use claims::JwtClaims; -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] pub(crate) use claims::numeric_date; pub use compact::{CompactJws, JwsHeader, Signer, encode_compact}; pub use ecdsa::{ecdsa_signature_from_der, normalize_ecdsa_signature}; diff --git a/src/lib.rs b/src/lib.rs index 70573f1..5ec1060 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,17 +4,20 @@ //! keys and signatures ([`jose`]), key stores ([`keys`]), the signing party ([`identity`]), DID //! resolution and dereferencing ([`resolver`]) and the `did:key`, `did:jwk` and `did:web` //! methods ([`methods`]). The upper layer builds JWT-based verifiable credentials and -//! presentations on top of it ([`vc`]), SD-JWT verifiable credentials ([`sd_jwt_vc`]), the DIF -//! Well Known DID Configuration ([`domain_linkage`]) and DIF Linked Verifiable Presentations -//! ([`linked_vp`]), and never leaks back into the lower layer. [`prelude`] gathers the common -//! types of the lower layer, [`prelude::credentials`] those of the upper layer. +//! presentations on top of it ([`vc`]), SD-JWT verifiable credentials ([`sd_jwt_vc`]), the IETF +//! Token Status List ([`status_list`]), the DIF Well Known DID Configuration +//! ([`domain_linkage`]) and DIF Linked Verifiable Presentations ([`linked_vp`]), and never leaks +//! back into the lower layer. [`prelude`] gathers the common types of the lower layer, +//! [`prelude::credentials`] those of the upper layer. //! //! # Cargo features //! //! All features are additive. The default set enables both layers over software keys and the //! three DID methods; `--no-default-features` leaves the lower layer with the in-crate //! parsers and verifiers only. The complete list, with what each feature pulls in, is in the -//! crate's `docs/DESIGN.md`; the ones that add surface rather than enable a module: +//! crate's `docs/DESIGN.md`. `status-list` enables [`status_list`] on its own, so a build with +//! `vc` but without `sd-jwt` checks the `status` claim of JWT credentials too; `sd-jwt` implies +//! it. The features that add surface rather than enable a module: //! //! - `utoipa`: OpenAPI schemas ([`utoipa::ToSchema`]) for [`DidDocument`](did::DidDocument), //! [`VerificationMethod`](did::VerificationMethod), [`Service`](did::Service), @@ -47,6 +50,9 @@ pub mod resolver; #[cfg(feature = "sd-jwt")] #[cfg_attr(docsrs, doc(cfg(feature = "sd-jwt")))] pub mod sd_jwt_vc; +#[cfg(feature = "status-list")] +#[cfg_attr(docsrs, doc(cfg(feature = "status-list")))] +pub mod status_list; #[cfg(feature = "vc")] #[cfg_attr(docsrs, doc(cfg(feature = "vc")))] pub mod vc; diff --git a/src/openapi.rs b/src/openapi.rs index f173a2f..8bdc2e9 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -664,12 +664,12 @@ mod type_metadata { // Token Status List // --------------------------------------------------------------------------------------------- -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] mod status { use super::{Cow, ObjectBuilder, PartialSchema, RefOr, Schema, ToSchema, Type, one_of, string}; - use crate::sd_jwt_vc::StatusType; + use crate::status_list::StatusType; - #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "status-list"))))] impl PartialSchema for StatusType { fn schema() -> RefOr { one_of( @@ -690,7 +690,7 @@ mod status { } } - #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "sd-jwt"))))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "status-list"))))] impl ToSchema for StatusType { fn name() -> Cow<'static, str> { Cow::Borrowed("StatusType") diff --git a/src/prelude.rs b/src/prelude.rs index 3734354..ee7c807 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -88,9 +88,12 @@ pub mod credentials { Disclosure, HashAlgorithm, KeyBinding, KeyBindingOptions, Processed, SdJwt, SdJwtBuilder, SdJwtCredentialV2, SdJwtCredentialV2Builder, SdJwtCredentialV2Error, SdJwtCredentialValidator, SdJwtError, SdJwtValidationError, SdJwtValidationOptions, - SdJwtVc, SdJwtVcBuilder, SdJwtVcClaims, SdJwtVcError, Status, StatusList, StatusListToken, - TypeMetadata, ValidatedSdJwtCredentialV2, ValidatedSdJwtVc, + SdJwtVc, SdJwtVcBuilder, SdJwtVcClaims, SdJwtVcError, TypeMetadata, + ValidatedSdJwtCredentialV2, ValidatedSdJwtVc, }; + #[cfg(feature = "status-list")] + #[cfg_attr(docsrs, doc(cfg(feature = "status-list")))] + pub use crate::status_list::{Status, StatusList, StatusListToken}; pub use crate::vc::validation::{ CredentialStatus, CredentialValidationOptions, DecodedCredential, DecodedCredentialV2, DecodedPresentation, DecodedPresentationV2, FailFast, JwtCredentialValidator, diff --git a/src/sd_jwt_vc/credential.rs b/src/sd_jwt_vc/credential.rs index ce1577a..dba5009 100644 --- a/src/sd_jwt_vc/credential.rs +++ b/src/sd_jwt_vc/credential.rs @@ -10,11 +10,12 @@ use serde_json::{Map, Value}; use super::{ ClaimPath, Disclosure, HashAlgorithm, KeyBinding, PathComponent, Processed, SdJwt, - SdJwtBuilder, SdJwtError, Status, + SdJwtBuilder, SdJwtError, }; use crate::did::DidUrl; use crate::jose::{Jwk, JwsHeader, Signer}; use crate::resolver::{KeyReference, KeyReferenceError}; +use crate::status_list::Status; use crate::vc::JwtClaims; /// The `typ` of the issuer-signed JWT of an SD-JWT VC, `dc+sd-jwt`. diff --git a/src/sd_jwt_vc/credential_v2.rs b/src/sd_jwt_vc/credential_v2.rs index 8894282..15d2d84 100644 --- a/src/sd_jwt_vc/credential_v2.rs +++ b/src/sd_jwt_vc/credential_v2.rs @@ -11,9 +11,10 @@ use serde_json::{Map, Value}; use super::{ ClaimPath, Confirmation, Disclosure, HashAlgorithm, KeyBinding, PathComponent, Processed, - SdJwt, SdJwtBuilder, SdJwtError, Status, + SdJwt, SdJwtBuilder, SdJwtError, }; use crate::jose::{Jwk, JwsHeader, Signer}; +use crate::status_list::Status; use crate::vc::{CredentialError, CredentialV2, JwtClaims}; /// Why an SD-JWT is not a VCDM 2.0 credential secured with SD-JWT. diff --git a/src/sd_jwt_vc/mod.rs b/src/sd_jwt_vc/mod.rs index 965cc2f..b83e6f6 100644 --- a/src/sd_jwt_vc/mod.rs +++ b/src/sd_jwt_vc/mod.rs @@ -15,9 +15,12 @@ //! §3.2.1): `typ: vc+sd-jwt`, the [`CredentialV2`](crate::vc::CredentialV2) itself as the //! payload, [`SdJwtCredentialV2Builder`] to issue one and //! [`SdJwtCredentialValidator::validate_v2`] to validate one. -//! - [`Status`], [`StatusListRef`] and [`StatusList`] implement the Token Status List -//! referenced from the `status` claim; [`TypeMetadata`] the SD-JWT VC type metadata with -//! its claim paths, integrity metadata and `extends` processing. +//! - [`TypeMetadata`] implements the SD-JWT VC type metadata with its claim paths, integrity +//! metadata and `extends` processing. +//! +//! The Token Status List referenced from the `status` claim is not specific to SD-JWT and +//! lives in [`status_list`](crate::status_list), which this feature enables. Its types are +//! still reachable here, deprecated, for one release. //! //! The SD-JWT layer is implemented here rather than taken from a dependency (ADR 0005). //! @@ -62,7 +65,6 @@ mod disclosure; mod error; mod hash; mod sd_jwt; -mod status; mod type_metadata; pub mod validation; @@ -76,10 +78,6 @@ pub use disclosure::Disclosure; pub use error::SdJwtError; pub use hash::HashAlgorithm; pub use sd_jwt::{DisclosedClaim, ELLIPSIS_KEY, KB_JWT_TYP, KeyBinding, Processed, SD_KEY, SdJwt}; -pub use status::{ - Status, StatusList, StatusListError, StatusListRef, StatusListToken, StatusMechanism, - StatusType, -}; pub use crate::vc::{ClaimPath, ClaimPathError, PathComponent}; pub use type_metadata::{ @@ -90,3 +88,28 @@ pub use validation::{ KeyBindingOptions, SdJwtCredentialValidator, SdJwtValidationError, SdJwtValidationOptions, ValidatedSdJwtCredentialV2, ValidatedSdJwtVc, }; + +// `#[deprecated]` has no effect on a `pub use` (rust-lang/rust#30827), so the old paths are +// deprecated type aliases. Remove them in the release after the next one. + +/// Moved to [`status_list::Status`](crate::status_list::Status). +#[deprecated(since = "0.3.0", note = "moved to `status_list::Status`")] +pub type Status = crate::status_list::Status; +/// Moved to [`status_list::StatusList`](crate::status_list::StatusList). +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusList`")] +pub type StatusList = crate::status_list::StatusList; +/// Moved to [`status_list::StatusListError`](crate::status_list::StatusListError). +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusListError`")] +pub type StatusListError = crate::status_list::StatusListError; +/// Moved to [`status_list::StatusListRef`](crate::status_list::StatusListRef). +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusListRef`")] +pub type StatusListRef = crate::status_list::StatusListRef; +/// Moved to [`status_list::StatusListToken`](crate::status_list::StatusListToken). +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusListToken`")] +pub type StatusListToken = crate::status_list::StatusListToken; +/// Moved to [`status_list::StatusMechanism`](crate::status_list::StatusMechanism). +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusMechanism`")] +pub type StatusMechanism<'a> = crate::status_list::StatusMechanism<'a>; +/// Moved to [`status_list::StatusType`](crate::status_list::StatusType). +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusType`")] +pub type StatusType = crate::status_list::StatusType; diff --git a/src/sd_jwt_vc/validation.rs b/src/sd_jwt_vc/validation.rs index 0f0baaf..3117928 100644 --- a/src/sd_jwt_vc/validation.rs +++ b/src/sd_jwt_vc/validation.rs @@ -29,7 +29,7 @@ //! holder's key is `cnf.jwk`, used as it is since the issuer signed it, or else the key //! `cnf.kid` names, an absolute DID URL resolved for [`KeyRole::Subject`] //! (`authentication`); see [`KeyReference::from_confirmation`]. -//! 5. `status` (as [`CredentialStatus::SdJwt`]) and the `credentialStatus` entries of a VCDM +//! 5. `status` (as [`CredentialStatus::StatusClaim`]) and the `credentialStatus` entries of a VCDM //! 2.0 credential (as [`CredentialStatus::Vcdm`]) pass the [`StatusCheck`], per the //! [`StatusPolicy`]. //! @@ -52,8 +52,11 @@ use crate::vc::validation::{ }; use crate::vc::{CredentialError, JwtClaims}; +/// Moved to [`status_list::StatusListCheck`](crate::status_list::StatusListCheck). #[cfg(feature = "did-web")] -pub use status_list::StatusListCheck; +#[cfg_attr(docsrs, doc(cfg(feature = "did-web")))] +#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusListCheck`")] +pub type StatusListCheck = crate::status_list::StatusListCheck; /// What a key binding JWT must prove (RFC 9901 §7.3, step 5). /// @@ -332,8 +335,11 @@ impl SdJwtCredentialValidator { None => (None, None), }; - self.check_status(claims.status.iter().map(CredentialStatus::SdJwt), options) - .await?; + self.check_status( + claims.status.iter().map(CredentialStatus::StatusClaim), + options, + ) + .await?; Ok(ValidatedSdJwtVc { credential, @@ -378,7 +384,7 @@ impl SdJwtCredentialValidator { .credential() .statuses() .map(CredentialStatus::Vcdm) - .chain(credential.status().map(CredentialStatus::SdJwt)), + .chain(credential.status().map(CredentialStatus::StatusClaim)), options, ) .await?; @@ -585,238 +591,3 @@ impl core::fmt::Debug for SdJwtCredent .finish() } } - -#[cfg(feature = "did-web")] -mod status_list { - 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::sd_jwt_vc::{StatusListError, StatusListToken, StatusMechanism}; - use crate::vc::Jwt; - use crate::vc::validation::{CredentialStatus, StatusCheck, StatusError}; - - type BoxError = Box; - - /// A [`StatusCheck`] for the Token Status List: for the `status` claim of an SD-JWT - /// credential or of a JWT credential ([`CredentialStatus::SdJwt`]), fetches the status - /// list token from the referenced URI with an [`HttpClient`], verifies it was signed by - /// the key its header names ([`KeyReference::from_jws`] with the token's `iss`), resolved - /// for [`KeyRole::Issuer`], and reads the status at the referenced index (Token Status - /// List §8). - /// - /// When the token has an `iss`, the key must belong to it ([`KeyReference::binds_to`]); - /// without one, only a key named by a DID URL is accepted, since a key by value or a key - /// set could be anybody's. Beyond that the check trusts the key the reference resolves - /// to; whether that key may speak for the list is the application's policy to enforce - /// around it, by restricting the resolver, say. - /// - /// # Fetching - /// - /// Every request asks for `application/statuslist+jwt` ([`StatusListToken::MEDIA_TYPE`]) - /// through [`HttpClient::get_accepting`]. `ReqwestClient` (feature `reqwest`) also - /// negotiates gzip, which the draft recommends Status Providers use. - /// - /// A 301, 302, 303, 307 or 308 response is followed, as the draft recommends: its - /// [`location`](HttpResponse::location) is resolved against the URL - /// that returned it and fetched with a new request through the same client, so that every - /// hop passes the client's checks. The check refuses a target that is not `https`, a URL - /// already visited (a loop), a redirect without a `Location`, and more than - /// [`MAX_REDIRECTS`](Self::MAX_REDIRECTS) redirects. - /// - /// The status list URI comes from the credential, and a redirect target from whoever serves - /// it, so the client fetches whatever an issuer names: `client` must enforce the protections - /// `ReqwestClient` does (HTTPS only, no redirects of its own, no non-public addresses after - /// name resolution, a body limit and a timeout), or the status check becomes a request - /// forgery primitive. - pub struct StatusListCheck { - client: C, - resolver: R, - verifier: V, - } - - impl StatusListCheck { - /// The most redirects one status list fetch follows. - pub const MAX_REDIRECTS: usize = 3; - } - - impl StatusListCheck { - /// A check fetching with `client`, resolving the status issuer's key with `resolver` - /// and verifying with `verifier`. - pub fn new(client: C, resolver: R, verifier: V) -> Self { - Self { - client, - resolver, - verifier, - } - } - - /// The response for `uri`, following redirects as the type documentation describes. - async fn get(&self, uri: &str) -> Result { - let unavailable = |reason: String, source: Option| 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; - } - } - - /// The status list token at `uri`, fetched and verified. - async fn fetch(&self, uri: &str) -> Result { - let invalid = |reason: &str, source: Option| 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 StatusCheck for StatusListCheck { - /// Checks the Token Status List reference of a `status` claim; a `credentialStatus` - /// entry, or a `status` claim without one, is [`StatusError::Unsupported`]. - async fn check( - &self, - status: CredentialStatus<'_>, - now: DateTime, - ) -> Result<(), StatusError> { - let CredentialStatus::SdJwt(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 core::fmt::Debug - for StatusListCheck - { - 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() - } - } -} diff --git a/src/status_list/check.rs b/src/status_list/check.rs new file mode 100644 index 0000000..37a88e7 --- /dev/null +++ b/src/status_list/check.rs @@ -0,0 +1,234 @@ +//! [`StatusListCheck`]: the Token Status List as a [`StatusCheck`], over an [`HttpClient`]. + +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; + +/// A [`StatusCheck`] for the Token Status List: for the `status` claim of an SD-JWT +/// credential or of a JWT credential ([`CredentialStatus::StatusClaim`]), fetches the status +/// list token from the referenced URI with an [`HttpClient`], verifies it was signed by +/// the key its header names ([`KeyReference::from_jws`] with the token's `iss`), resolved +/// for [`KeyRole::Issuer`], and reads the status at the referenced index (Token Status +/// List §8). +/// +/// When the token has an `iss`, the key must belong to it ([`KeyReference::binds_to`]); +/// without one, only a key named by a DID URL is accepted, since a key by value or a key +/// set could be anybody's. Beyond that the check trusts the key the reference resolves +/// to; whether that key may speak for the list is the application's policy to enforce +/// around it, by restricting the resolver, say. +/// +/// # Fetching +/// +/// Every request asks for `application/statuslist+jwt` ([`StatusListToken::MEDIA_TYPE`]) +/// through [`HttpClient::get_accepting`]. `ReqwestClient` (feature `reqwest`) also +/// negotiates gzip, which the draft recommends Status Providers use. +/// +/// A 301, 302, 303, 307 or 308 response is followed, as the draft recommends: its +/// [`location`](HttpResponse::location) is resolved against the URL +/// that returned it and fetched with a new request through the same client, so that every +/// hop passes the client's checks. The check refuses a target that is not `https`, a URL +/// already visited (a loop), a redirect without a `Location`, and more than +/// [`MAX_REDIRECTS`](Self::MAX_REDIRECTS) redirects. +/// +/// The status list URI comes from the credential, and a redirect target from whoever serves +/// it, so the client fetches whatever an issuer names: `client` must enforce the protections +/// `ReqwestClient` does (HTTPS only, no redirects of its own, no non-public addresses after +/// name resolution, a body limit and a timeout), or the status check becomes a request +/// forgery primitive. +pub struct StatusListCheck { + client: C, + resolver: R, + verifier: V, +} + +impl StatusListCheck { + /// The most redirects one status list fetch follows. + pub const MAX_REDIRECTS: usize = 3; +} + +impl StatusListCheck { + /// A check fetching with `client`, resolving the status issuer's key with `resolver` + /// and verifying with `verifier`. + pub fn new(client: C, resolver: R, verifier: V) -> Self { + Self { + client, + resolver, + verifier, + } + } + + /// The response for `uri`, following redirects as the type documentation describes. + async fn get(&self, uri: &str) -> Result { + let unavailable = |reason: String, source: Option| 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; + } + } + + /// The status list token at `uri`, fetched and verified. + async fn fetch(&self, uri: &str) -> Result { + let invalid = |reason: &str, source: Option| 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 StatusCheck for StatusListCheck { + /// Checks the Token Status List reference of a `status` claim; a `credentialStatus` + /// entry, or a `status` claim without one, is [`StatusError::Unsupported`]. + async fn check( + &self, + status: CredentialStatus<'_>, + now: DateTime, + ) -> 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 core::fmt::Debug + for StatusListCheck +{ + 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() + } +} diff --git a/src/sd_jwt_vc/status.rs b/src/status_list/mod.rs similarity index 93% rename from src/sd_jwt_vc/status.rs rename to src/status_list/mod.rs index af7b6c1..b61855a 100644 --- a/src/sd_jwt_vc/status.rs +++ b/src/status_list/mod.rs @@ -1,5 +1,23 @@ -//! The Token Status List: the `status` claim of a referenced token, the status list and the -//! status list token. +//! The IETF Token Status List (draft-ietf-oauth-status-list): the `status` claim of a +//! referenced token, the status list and the status list token. +//! +//! The draft defines the `status` claim for any JWT (§6.1), so the module serves every +//! credential format of the crate: SD-JWT VCs and VCDM 2.0 credentials secured with SD-JWT +//! ([`sd_jwt_vc`](crate::sd_jwt_vc), feature `sd-jwt`) as much as JWT credentials of either +//! profile ([`vc`](crate::vc)). The validators of both hand the claim to a +//! [`StatusCheck`](crate::vc::validation::StatusCheck) as +//! [`CredentialStatus::StatusClaim`](crate::vc::validation::CredentialStatus::StatusClaim). +//! +//! - [`Status`] is the claim, [`StatusListRef`] its `status_list` mechanism and +//! [`StatusMechanism`] one mechanism of it. +//! - [`StatusList`] is the list (§4): one [`StatusType`] of 1, 2, 4 or 8 bits per referenced +//! token, ZLIB-compressed in its `lst` member. +//! - [`StatusListToken`] is the signed list (§5.1), issued with +//! [`sign`](StatusListToken::sign) and read with [`status_of`](StatusListToken::status_of). +//! - `StatusListCheck` (feature `did-web` as well) is the [`StatusCheck`] that fetches the +//! token a reference names over an `HttpClient` and verifies it (§8). +//! +//! [`StatusCheck`]: crate::vc::validation::StatusCheck use core::fmt; use core::str::FromStr; @@ -13,13 +31,20 @@ use crate::cause::cause; use crate::jose::{JwsError, JwsHeader, Signer, base64url}; use crate::vc::{Jwt, JwtClaims}; +#[cfg(feature = "did-web")] +mod check; + +#[cfg(feature = "did-web")] +#[cfg_attr(docsrs, doc(cfg(feature = "did-web")))] +pub use check::StatusListCheck; + /// The `status` claim of a referenced token: at least one status mechanism, `status_list` /// being the one this crate implements. /// /// # Examples /// /// ``` -/// use identity_kit::sd_jwt_vc::{Status, StatusListRef}; +/// use identity_kit::status_list::{Status, StatusListRef}; /// use serde_json::json; /// /// // Token Status List §6.2. @@ -131,7 +156,7 @@ impl StatusListRef { /// # Examples /// /// ``` -/// use identity_kit::sd_jwt_vc::StatusType; +/// use identity_kit::status_list::StatusType; /// use serde_json::json; /// /// assert_eq!(serde_json::to_value(StatusType::SUSPENDED)?, json!("SUSPENDED")); @@ -335,7 +360,7 @@ pub enum StatusListError { /// # Examples /// /// ``` -/// use identity_kit::sd_jwt_vc::{StatusList, StatusType}; +/// use identity_kit::status_list::{StatusList, StatusType}; /// use serde_json::json; /// /// // Token Status List §4.1: 16 statuses of 1 bit. @@ -554,7 +579,7 @@ impl<'de> Deserialize<'de> for StatusList { /// /// ``` /// use chrono::{TimeZone as _, Utc}; -/// use identity_kit::sd_jwt_vc::{StatusListRef, StatusListToken, StatusType}; +/// use identity_kit::status_list::{StatusListRef, StatusListToken, StatusType}; /// use identity_kit::vc::Jwt; /// /// // Token Status List §8.2. diff --git a/src/vc/jwt.rs b/src/vc/jwt.rs index e5c0bb3..2645f3d 100644 --- a/src/vc/jwt.rs +++ b/src/vc/jwt.rs @@ -8,7 +8,7 @@ use chrono::{DateTime, Utc}; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize}; -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] use crate::jose::numeric_date; use crate::jose::{CompactJws, JwsError, JwsHeader, JwtClaims, Signer, encode_compact}; @@ -144,7 +144,7 @@ fn registered_in_additional(claims: &JwtClaims) -> Option<&'static str> { } /// As [`numeric_date`], for a claim that must be present. -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] pub(crate) fn required_numeric_date<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result { diff --git a/src/vc/mod.rs b/src/vc/mod.rs index aa66418..1b4e8a8 100644 --- a/src/vc/mod.rs +++ b/src/vc/mod.rs @@ -79,11 +79,11 @@ pub mod validation; pub use credential::Credential; pub use error::CredentialError; pub use jwt::Jwt; -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] pub(crate) use jwt::required_numeric_date; pub use crate::jose::JwtClaims; -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] pub(crate) use crate::jose::numeric_date; pub use model::{CONTEXT_V1, CONTEXT_V2, Party, Subject, TypedObject}; pub use path::{ClaimPath, ClaimPathError, PathComponent}; diff --git a/src/vc/validation/mod.rs b/src/vc/validation/mod.rs index 46610b9..1a3f878 100644 --- a/src/vc/validation/mod.rs +++ b/src/vc/validation/mod.rs @@ -40,10 +40,10 @@ //! time, within the leeway. //! 6. `credentialStatus` passes the [`StatusCheck`], per the [`StatusPolicy`], and so does a //! `status` claim, which the Token Status List defines for any JWT (§6.1), as -//! `CredentialStatus::SdJwt` after the `credentialStatus` entries. With the `sd-jwt` -//! feature a `status` claim that does not parse, or names no status mechanism, is -//! refused; without it the claim cannot be checked, so it fails under -//! [`StatusPolicy::Strict`] and is ignored otherwise. +//! `CredentialStatus::StatusClaim` after the `credentialStatus` entries. With the +//! `status-list` feature (which `sd-jwt` implies) a `status` claim that does not parse, or +//! names no status mechanism, is refused; without it the claim cannot be checked, so it +//! fails under [`StatusPolicy::Strict`] and is ignored otherwise. //! //! For a presentation ([`JwtPresentationValidator`]): the same for the presentation JWT, with //! the key resolved for [`KeyRole::Subject`] (`authentication`) and bound to the holder, plus the expected @@ -111,11 +111,11 @@ pub use presentation::{ pub enum CredentialStatus<'a> { /// A `credentialStatus` entry of a VCDM credential. Vcdm(&'a TypedObject), - /// The `status` claim of an SD-JWT VC, of a VCDM 2.0 credential secured with SD-JWT, or of - /// a JWT credential of either profile (IETF Token Status List §6.1 and other mechanisms). - #[cfg(feature = "sd-jwt")] - #[cfg_attr(docsrs, doc(cfg(feature = "sd-jwt")))] - SdJwt(&'a crate::sd_jwt_vc::Status), + /// The `status` claim of a JWT credential of either profile, of an SD-JWT VC or of a VCDM + /// 2.0 credential secured with SD-JWT (IETF Token Status List §6.1 and other mechanisms). + #[cfg(feature = "status-list")] + #[cfg_attr(docsrs, doc(cfg(feature = "status-list")))] + StatusClaim(&'a crate::status_list::Status), } impl CredentialStatus<'_> { @@ -125,12 +125,12 @@ impl CredentialStatus<'_> { pub fn mechanism(&self) -> String { match self { Self::Vcdm(entry) => entry.object_type().to_owned(), - #[cfg(feature = "sd-jwt")] - Self::SdJwt(status) => status + #[cfg(feature = "status-list")] + Self::StatusClaim(status) => status .mechanisms() .map(|mechanism| match mechanism { - crate::sd_jwt_vc::StatusMechanism::StatusList(_) => "status_list", - crate::sd_jwt_vc::StatusMechanism::Other { name, .. } => name, + crate::status_list::StatusMechanism::StatusList(_) => "status_list", + crate::status_list::StatusMechanism::Other { name, .. } => name, }) .collect::>() .join(", "), @@ -142,12 +142,12 @@ impl CredentialStatus<'_> { /// points to and reports whether the credential is still good. /// /// One check serves both credential formats: the validators hand it every `credentialStatus` -/// entry of a VCDM credential as [`CredentialStatus::Vcdm`] and the `status` claim of an -/// SD-JWT credential, or of a JWT credential, as `CredentialStatus::SdJwt` (feature -/// `sd-jwt`), so an application implements status checking once. A check that does not -/// understand an entry reports [`StatusError::Unsupported`], which the [`StatusPolicy`] -/// decides about. `StatusListCheck` of `sd_jwt_vc::validation` (features `sd-jwt` and -/// `did-web`) checks the Token Status List and reports every VCDM entry as unsupported. +/// entry of a VCDM credential as [`CredentialStatus::Vcdm`] and the `status` claim of a JWT +/// or SD-JWT credential as `CredentialStatus::StatusClaim` (feature `status-list`), so an +/// application implements status checking once. A check that does not understand an entry +/// reports [`StatusError::Unsupported`], which the [`StatusPolicy`] decides about. +/// `StatusListCheck` of `status_list` (features `status-list` and `did-web`) checks the Token +/// Status List and reports every VCDM entry as unsupported. /// /// # Examples /// @@ -163,8 +163,8 @@ impl CredentialStatus<'_> { /// impl StatusCheck for Revoked { /// async fn check(&self, status: CredentialStatus<'_>, _: DateTime) -> Result<(), StatusError> { /// match status { -/// #[cfg(feature = "sd-jwt")] -/// CredentialStatus::SdJwt(status) if status.status_list.is_some() => { +/// #[cfg(feature = "status-list")] +/// CredentialStatus::StatusClaim(status) if status.status_list.is_some() => { /// Err(StatusError::Invalid("revoked".to_owned())) /// } /// other => Err(StatusError::Unsupported(other.mechanism())), @@ -238,19 +238,19 @@ pub(crate) async fn check_statuses<'a>( } /// The `status` claim of a JWT credential, which the Token Status List defines for any JWT -/// (§6.1): a `sd_jwt_vc::Status` with the `sd-jwt` feature, uninhabited without it. -#[cfg(feature = "sd-jwt")] -pub(super) type StatusClaim = crate::sd_jwt_vc::Status; +/// (§6.1): a `status_list::Status` with the `status-list` feature, uninhabited without it. +#[cfg(feature = "status-list")] +pub(super) type StatusClaim = crate::status_list::Status; /// The `status` claim of a JWT credential, which the Token Status List defines for any JWT -/// (§6.1): a `sd_jwt_vc::Status` with the `sd-jwt` feature, uninhabited without it. -#[cfg(not(feature = "sd-jwt"))] +/// (§6.1): a `status_list::Status` with the `status-list` feature, uninhabited without it. +#[cfg(not(feature = "status-list"))] pub(super) enum StatusClaim {} /// The `status` claim of `claims`, for the [`StatusCheck`] beside the `credentialStatus` /// entries. /// -/// With the `sd-jwt` feature the claim is parsed, and one that does not parse, or names no +/// With the `status-list` feature the claim is parsed, and one that does not parse, or names no /// status mechanism, is an [`InvalidClaim`](crate::vc::CredentialError::InvalidClaim) /// whatever the `policy`. Without it the claim cannot be handed to a check, so it fails under /// [`StatusPolicy::Strict`] as an unchecked entry and is ignored otherwise. @@ -261,7 +261,7 @@ pub(super) fn status_claim( let Some(status) = claims.additional.get("status") else { return Ok(None); }; - #[cfg(feature = "sd-jwt")] + #[cfg(feature = "status-list")] { let _ = policy; let invalid = |reason: String| crate::vc::CredentialError::InvalidClaim { @@ -276,7 +276,7 @@ pub(super) fn status_claim( } Ok(Some(status)) } - #[cfg(not(feature = "sd-jwt"))] + #[cfg(not(feature = "status-list"))] { let _ = status; match policy { @@ -288,11 +288,11 @@ pub(super) fn status_claim( /// `status` as the entry a [`StatusCheck`] is handed. pub(super) fn status_claim_entry(status: &StatusClaim) -> CredentialStatus<'_> { - #[cfg(feature = "sd-jwt")] + #[cfg(feature = "status-list")] { - CredentialStatus::SdJwt(status) + CredentialStatus::StatusClaim(status) } - #[cfg(not(feature = "sd-jwt"))] + #[cfg(not(feature = "status-list"))] { match *status {} } diff --git a/src/vc/validation/presentation.rs b/src/vc/validation/presentation.rs index 7aa9b4c..9fef139 100644 --- a/src/vc/validation/presentation.rs +++ b/src/vc/validation/presentation.rs @@ -349,7 +349,7 @@ impl JwtPresentationValidator { .credential() .statuses() .map(CredentialStatus::Vcdm) - .chain(credential.status().map(CredentialStatus::SdJwt)), + .chain(credential.status().map(CredentialStatus::StatusClaim)), options.credentials.status, options.credentials.time.now(), ) diff --git a/tests/openapi.rs b/tests/openapi.rs index 1a02ffc..bf17c69 100644 --- a/tests/openapi.rs +++ b/tests/openapi.rs @@ -30,8 +30,8 @@ fn openapi() -> Value { add::(&mut schemas); #[cfg(feature = "sd-jwt")] add::(&mut schemas); - #[cfg(feature = "sd-jwt")] - add::(&mut schemas); + #[cfg(feature = "status-list")] + add::(&mut schemas); document(schemas) } @@ -281,10 +281,11 @@ fn every_schema_compiles_with_its_references_resolved() { "DisplayMetadata", "ClaimMetadata", "ClaimDisplay", - "StatusType", ] { assert!(components.contains_key(name), "{name}"); } + #[cfg(feature = "status-list")] + assert!(components.contains_key("StatusType")); for name in components.keys() { Validator::of(name); } @@ -333,8 +334,8 @@ fn every_schema_names_the_schemas_it_refers_to() { alone::(); #[cfg(feature = "sd-jwt")] alone::(); - #[cfg(feature = "sd-jwt")] - alone::(); + #[cfg(feature = "status-list")] + alone::(); } #[test] @@ -642,10 +643,10 @@ fn type_metadata_examples_validate() { } } -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] #[test] fn status_type_schema() { - use identity_kit::sd_jwt_vc::StatusType; + use identity_kit::status_list::StatusType; // The schema takes what `StatusType` serializes to: a name of its own, or a number. let validator = Validator::of("StatusType"); diff --git a/tests/sd_jwt_v2.rs b/tests/sd_jwt_v2.rs index b2bdb89..b98fdba 100644 --- a/tests/sd_jwt_v2.rs +++ b/tests/sd_jwt_v2.rs @@ -29,8 +29,9 @@ use identity_kit::sd_jwt_vc::{ Disclosure, HashAlgorithm, KeyBinding, KeyBindingOptions, SdJwt, SdJwtBuilder, SdJwtCredentialV2, SdJwtCredentialV2Builder, SdJwtCredentialV2Error, SdJwtCredentialValidator, SdJwtError, SdJwtValidationError, SdJwtValidationOptions, SdJwtVc, SdJwtVcBuilder, - SdJwtVcError, Status, StatusListRef, + SdJwtVcError, }; +use identity_kit::status_list::{Status, StatusListRef}; use identity_kit::vc::validation::{ CredentialStatus, CredentialValidationOptions, FailFast, JwtPresentationValidator, KeyReference, KeyReferenceError, KeyResolutionError, KeyResolver, KeyRole, @@ -886,7 +887,7 @@ impl StatusCheck for Verdicts { assert_eq!(entry.object_type(), "BitstringStatusListEntry"); self.vcdm } - CredentialStatus::SdJwt(status) => { + CredentialStatus::StatusClaim(status) => { assert_eq!(status.status_list.as_ref().unwrap().idx, 7); self.sd_jwt } diff --git a/tests/sd_jwt_validation.rs b/tests/sd_jwt_validation.rs index dc500f2..6d724fd 100644 --- a/tests/sd_jwt_validation.rs +++ b/tests/sd_jwt_validation.rs @@ -30,8 +30,9 @@ use identity_kit::sd_jwt_vc::validation::{KeyBindingOptions, SdJwtValidationOpti use identity_kit::sd_jwt_vc::{ Disclosure, HashAlgorithm, KeyBinding, LEGACY_TYP, SdJwt, SdJwtBuilder, SdJwtCredentialV2Error, SdJwtCredentialValidator, SdJwtError, SdJwtValidationError, SdJwtVc, SdJwtVcBuilder, - SdJwtVcError, Status, StatusList, StatusListRef, StatusListToken, StatusType, + SdJwtVcError, }; +use identity_kit::status_list::{Status, StatusList, StatusListRef, StatusListToken, StatusType}; use identity_kit::vc::validation::{ CredentialStatus, StatusCheck, StatusError, StatusPolicy, TimeOptions, ValidationError, }; @@ -591,7 +592,7 @@ impl StatusCheck for FixedStatus { status: CredentialStatus<'_>, _: DateTime, ) -> Result<(), StatusError> { - let CredentialStatus::SdJwt(Status { + let CredentialStatus::StatusClaim(Status { status_list: Some(reference), .. }) = status @@ -736,8 +737,8 @@ mod status_list_check { use identity_kit::jose::{JwsError, VerificationError}; use identity_kit::methods::web::{HttpClient, HttpError, HttpResponse}; use identity_kit::resolver::{CompositeKeyResolver, JwkResolver, KeyResolutionError}; - use identity_kit::sd_jwt_vc::StatusListError; - use identity_kit::sd_jwt_vc::validation::StatusListCheck; + use identity_kit::status_list::StatusListCheck; + use identity_kit::status_list::StatusListError; use identity_kit::vc::TypedObject; use url::Url; @@ -1051,14 +1052,19 @@ mod status_list_check { // So is a `status` claim without a status list reference. let other = Status::new().with_mechanism("other", json!({})); assert!(matches!( - check.check(CredentialStatus::SdJwt(&other), now).await, + check.check(CredentialStatus::StatusClaim(&other), now).await, Err(StatusError::Unsupported(kind)) if kind == "other" )); // A reference is fetched: here, from nowhere. let listed = Status::status_list(StatusListRef::new(LIST_URI, 0)); - assert_eq!(CredentialStatus::SdJwt(&listed).mechanism(), "status_list"); + assert_eq!( + CredentialStatus::StatusClaim(&listed).mechanism(), + "status_list" + ); assert!(matches!( - check.check(CredentialStatus::SdJwt(&listed), now).await, + check + .check(CredentialStatus::StatusClaim(&listed), now) + .await, Err(StatusError::Unavailable { .. }) )); } @@ -1199,7 +1205,7 @@ mod status_list_check { let status = Status::status_list(StatusListRef::new(format!("{base}/statuslists/{n}"), 0)); let result = check - .check(CredentialStatus::SdJwt(&status), issued_at()) + .check(CredentialStatus::StatusClaim(&status), issued_at()) .await; assert!(result.is_ok(), "{n}: {result:?}"); } @@ -1250,7 +1256,7 @@ mod status_list_check { async move { let status = Status::status_list(StatusListRef::new(uri, 0)); check - .check(CredentialStatus::SdJwt(&status), issued_at()) + .check(CredentialStatus::StatusClaim(&status), issued_at()) .await } }; diff --git a/tests/sd_jwt_vc.rs b/tests/sd_jwt_vc.rs index 5a7b7b2..338121f 100644 --- a/tests/sd_jwt_vc.rs +++ b/tests/sd_jwt_vc.rs @@ -24,9 +24,12 @@ use identity_kit::claim_path; use identity_kit::jose::{DefaultVerifier, Jwk}; use identity_kit::sd_jwt_vc::{ ClaimMetadata, ClaimPath, Integrity, IntegrityAlgorithm, IntegrityError, LEGACY_TYP, - NON_DISCLOSABLE_CLAIMS, SdJwt, SdJwtError, SdJwtVc, SdJwtVcClaims, SdJwtVcError, SdPolicy, + NON_DISCLOSABLE_CLAIMS, SdJwt, SdJwtError, SdJwtVc, SdJwtVcClaims, SdJwtVcError, SdPolicy, TYP, + TypeMetadata, TypeMetadataError, +}; +use identity_kit::status_list::{ Status, StatusList, StatusListError, StatusListRef, StatusListToken, StatusMechanism, - StatusType, TYP, TypeMetadata, TypeMetadataError, + StatusType, }; use identity_kit::vc::{Jwt, JwtClaims}; use serde_json::{Map, Value, json}; @@ -424,6 +427,50 @@ fn status_claim_of_a_referenced_token() { assert_eq!(processed.payload["address"]["locality"], "Schulpforta"); } +/// The Token Status List moved to `status_list`; its old paths in `sd_jwt_vc` stay for one +/// release, deprecated, and name the same types. +#[test] +#[expect(deprecated, reason = "the old paths are what this test exercises")] +fn deprecated_sd_jwt_vc_paths_still_work() { + use identity_kit::{sd_jwt_vc, status_list}; + + let reference = sd_jwt_vc::StatusListRef::new("https://example.com/statuslists/1", 1); + let status: status_list::Status = sd_jwt_vc::Status::status_list(reference.clone()); + assert!(matches!( + status.mechanisms().next(), + Some(sd_jwt_vc::StatusMechanism::StatusList(r)) if *r == reference + )); + + let mut list: status_list::StatusList = sd_jwt_vc::StatusList::new(1, 8).unwrap(); + list.set(1, sd_jwt_vc::StatusType::INVALID).unwrap(); + let error: sd_jwt_vc::StatusListError = list.set(8, StatusType::VALID).unwrap_err(); + assert!(matches!( + error, + StatusListError::IndexOutOfRange { idx: 8, len: 8 } + )); + let token: sd_jwt_vc::StatusListToken = StatusListToken::new( + reference.uri.clone(), + Utc.timestamp_opt(0, 0).unwrap(), + list, + ); + assert_eq!( + token + .status_of(&reference, Utc.timestamp_opt(1, 0).unwrap()) + .unwrap(), + StatusType::INVALID + ); + + #[cfg(feature = "did-web")] + { + fn same( + check: sd_jwt_vc::validation::StatusListCheck, + ) -> status_list::StatusListCheck { + check + } + let _ = same::<(), ()>; + } +} + #[test] fn status_type_names_and_numbers() { // Token Status List, "OAuth Status Types" registry: the names of their own serialize as diff --git a/tests/vc_validation.rs b/tests/vc_validation.rs index 7ffaadf..cac29ba 100644 --- a/tests/vc_validation.rs +++ b/tests/vc_validation.rs @@ -1191,7 +1191,7 @@ async fn issue_with_status_claim(issuer: &Issuer, credential: &Credential, idx: .await } -#[cfg(feature = "sd-jwt")] +#[cfg(feature = "status-list")] mod status_claim { use std::sync::Mutex; @@ -1211,7 +1211,7 @@ mod status_claim { ) -> Result<(), StatusError> { self.0.lock().unwrap().push(status.mechanism()); match status { - CredentialStatus::SdJwt(status) => match &status.status_list { + CredentialStatus::StatusClaim(status) => match &status.status_list { Some(reference) if reference.idx == 1 => { Err(StatusError::Invalid("revoked".to_owned())) } @@ -1386,8 +1386,8 @@ mod status_claim { #[tokio::test] async fn status_list_check_reads_the_status_claim() { use identity_kit::methods::web::{HttpClient, HttpError, HttpResponse}; - use identity_kit::sd_jwt_vc::validation::StatusListCheck; - use identity_kit::sd_jwt_vc::{StatusList, StatusListToken, StatusType}; + use identity_kit::status_list::StatusListCheck; + use identity_kit::status_list::{StatusList, StatusListToken, StatusType}; use url::Url; /// A client serving one status list token at [`LIST_URI`]. @@ -1433,10 +1433,10 @@ mod status_claim { } } -/// Without the `sd-jwt` feature the claim cannot reach a check, so `Strict` refuses it. -#[cfg(not(feature = "sd-jwt"))] +/// Without the `status-list` feature the claim cannot reach a check, so `Strict` refuses it. +#[cfg(not(feature = "status-list"))] #[tokio::test] -async fn status_claim_without_sd_jwt_feature() { +async fn status_claim_without_status_list_feature() { let (issuer, holder) = (issuer(), holder()); let credential = credential(&issuer.did, &holder.did); let jwt = issue_with_status_claim(&issuer, &credential, 1).await;