diff --git a/README.md b/README.md index c32c9a8..8d94da7 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ All features are additive; the lower layer (`did`, `jose`, `keys`, `identity`, ` | `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` | -| `utoipa` | | OpenAPI schemas (`utoipa::ToSchema`) for `DidDocument`, `VerificationMethod`, `Service`, `Jwk`, `DomainLinkageConfiguration`, `ClaimPath` and `TypeMetadata` | +| `utoipa` | | OpenAPI schemas (`utoipa::ToSchema`) for `DidDocument`, `VerificationMethod`, `Service`, `Jwk`, `JwsAlgorithm`, `Credential`, `Presentation`, `CredentialV2`, `PresentationV2`, `EnvelopedVc`, `EnvelopedVp`, `DomainLinkageConfiguration`, `ClaimPath`, `TypeMetadata` and `StatusType` | | `test-vectors` | | `keys::testsuite`, the behavioural suite every key store must pass, and `vectors`, the specification vectors | `identity_kit::prelude` gathers the common types of the lower layer and `identity_kit::prelude::credentials` those of the upper layer, for glob import. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 21c7ccd..519ab51 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -83,7 +83,7 @@ All additive. CI runs `cargo hack --each-feature` and the no-default-features bu | `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 | -| `utoipa` | off | `utoipa`; OpenAPI schemas for `DidDocument`, `VerificationMethod`, `Service`, `Jwk`, `DomainLinkageConfiguration`, `ClaimPath` and `TypeMetadata` | +| `utoipa` | off | `utoipa`; OpenAPI schemas for `DidDocument`, `VerificationMethod`, `Service`, `Jwk`, `JwsAlgorithm`, the VCDM 1.1 and 2.0 credentials and presentations (`Credential`, `Presentation`, `CredentialV2`, `PresentationV2`, `EnvelopedVc`, `EnvelopedVp`), `DomainLinkageConfiguration`, `ClaimPath`, `TypeMetadata` and `StatusType` | | `test-vectors` | off | nothing; `keys::testsuite` and `vectors`, the specification vectors the tests run against | ## `did` module diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 659298c..e4d80cc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -137,6 +137,7 @@ additive; see [`DESIGN.md`](DESIGN.md#features) for what each one pulls in. | Feature | Status | Cargo feature | Specification | Issue | | ------------------------------------------------------------------------------ | :----: | ------------- | -------------------------------------------- | --------------------------------------------------------- | | OpenAPI schemas for the public types | ✅ | `utoipa` | [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) | [#14](https://github.com/impierce/identity-kit/issues/14) | +| OpenAPI schemas for `JwsAlgorithm` and the credentials and presentations of `vc`: `Credential`, `Presentation`, `CredentialV2`, `PresentationV2`, `EnvelopedVc`, `EnvelopedVp` | ✅ | `utoipa`, `vc` | [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0), [VCDM 1.1][vcdm11], [VCDM 2.0][vcdm20] | [#122](https://github.com/impierce/identity-kit/issues/122) | | Re-exported conformance vectors (`vectors`) | ✅ | `test-vectors` | | [#14](https://github.com/impierce/identity-kit/issues/14) | | CI proof of the layer boundary (`--no-default-features --features did-key,software` builds) | ✅ | | | [#14](https://github.com/impierce/identity-kit/issues/14) | | `cargo hack --each-feature` and `cargo publish --dry-run` in CI; `cargo-semver-checks` through release-plz | ✅ | | | [#14](https://github.com/impierce/identity-kit/issues/14) | diff --git a/src/lib.rs b/src/lib.rs index 70573f1..ea8a238 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,9 @@ //! //! - `utoipa`: OpenAPI schemas ([`utoipa::ToSchema`]) for [`DidDocument`](did::DidDocument), //! [`VerificationMethod`](did::VerificationMethod), [`Service`](did::Service), -//! [`Jwk`](jose::Jwk), `DomainLinkageConfiguration` and `TypeMetadata`. +//! [`Jwk`](jose::Jwk), [`JwsAlgorithm`](jose::JwsAlgorithm), the credentials and +//! presentations of `vc` (VCDM 1.1 and 2.0, enveloped forms included), +//! `DomainLinkageConfiguration`, `ClaimPath`, `TypeMetadata` and `StatusType`. //! - `test-vectors`: [`keys::testsuite`], the behavioural suite every key store must pass, and //! [`vectors`], the specification vectors the tests of this crate run against. #![cfg_attr(docsrs, feature(doc_cfg))] diff --git a/src/openapi.rs b/src/openapi.rs index f173a2f..d316c0b 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -1,7 +1,9 @@ //! OpenAPI schemas for the JSON types (feature `utoipa`): [`utoipa::ToSchema`] for //! [`DidDocument`], [`VerificationMethod`], [`MethodRef`], [`Service`], [`Jwk`], -//! `DomainLinkageConfiguration`, `ClaimPath`, `TypeMetadata` with its display and claim -//! metadata, and `StatusType`. +//! [`JwsAlgorithm`], `DomainLinkageConfiguration`, the credentials and presentations of `vc` +//! (`Credential`, `Presentation`, `CredentialV2`, `PresentationV2`, `EnvelopedVc`, +//! `EnvelopedVp`), `ClaimPath`, `TypeMetadata` with its display and claim metadata, and +//! `StatusType`. //! //! The schemas are written by hand rather than derived: every type carries open extension //! properties and custom serialization that a derive cannot express. Each schema describes the @@ -18,7 +20,7 @@ use utoipa::openapi::schema::{ use utoipa::{PartialSchema, ToSchema}; use crate::did::{DidDocument, MethodRef, Service, VerificationMethod}; -use crate::jose::Jwk; +use crate::jose::{Jwk, JwsAlgorithm}; /// DID Core §3.1 as a regular expression: `did:`, a method name of lowercase letters and /// digits, then a method-specific id of `idchar`s and `:` that does not end in `:`. @@ -387,6 +389,25 @@ impl ToSchema for Jwk { } } +#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))] +impl PartialSchema for JwsAlgorithm { + fn schema() -> RefOr { + string( + "A JWS algorithm (RFC 7518 §3.1, RFC 8037, RFC 8812): its registered name, among \ + the asymmetric algorithms this crate speaks.", + ) + .enum_values(Some(JwsAlgorithm::ALL.iter().map(|alg| alg.as_str()))) + .into() + } +} + +#[cfg_attr(docsrs, doc(cfg(feature = "utoipa")))] +impl ToSchema for JwsAlgorithm { + fn name() -> Cow<'static, str> { + Cow::Borrowed("JwsAlgorithm") + } +} + // --------------------------------------------------------------------------------------------- // DIF Well Known DID Configuration // --------------------------------------------------------------------------------------------- @@ -440,6 +461,365 @@ impl ToSchema for crate::domain_linkage::DomainLinkageConfiguration { } } +// --------------------------------------------------------------------------------------------- +// Verifiable credentials and presentations +// --------------------------------------------------------------------------------------------- + +#[cfg(feature = "vc")] +mod credentials { + use super::{ + AdditionalProperties, ArrayBuilder, Cow, ObjectBuilder, OneOfBuilder, PartialSchema, RefOr, + Schema, SchemaFormat, SchemaType, Schemas, ToSchema, array, collect, one_of, schema_ref, + string, string_or_strings, uri, + }; + use crate::vc::{ + CONTEXT_V1, CONTEXT_V2, Credential, CredentialV2, EnvelopedVc, EnvelopedVp, Presentation, + PresentationV2, + }; + + /// A compact JWS: three base64url parts joined by dots. + const JWS_PATTERN: &str = r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*$"; + + /// `@context` beginning with `base`: the URI alone, or an array of URIs and objects whose + /// first element is it. + fn context(base: &'static str, section: &str) -> OneOfBuilder { + one_of( + [ + string("The base context alone.") + .enum_values(Some([base])) + .into(), + ArrayBuilder::new() + .prefix_items([Schema::from( + string("The base context.").enum_values(Some([base])), + )]) + .items(ObjectBuilder::new().schema_type(SchemaType::AnyValue)) + .min_items(Some(1)) + .description(Some("The base context first, then further contexts.")) + .into(), + ], + &format!("The JSON-LD context, carried as data ({section}); it begins with `{base}`."), + ) + } + + /// The `type` of an object that must include `required`. + fn types(required: &str, section: &str) -> OneOfBuilder { + string_or_strings( + "A type.", + &format!("The types ({section}), including `{required}`."), + ) + } + + fn date_time(description: &str) -> ObjectBuilder { + string(description).format(Some(SchemaFormat::KnownFormat( + utoipa::openapi::KnownFormat::DateTime, + ))) + } + + /// An issuer or holder: its URI, or an object with its URI as `id`. + fn party(description: &str) -> OneOfBuilder { + one_of( + [ + uri("The identifier.").into(), + ObjectBuilder::new() + .property("id", uri("The identifier.")) + .required("id") + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .description(Some("The identifier with further properties.")) + .into(), + ], + description, + ) + } + + fn subject() -> ObjectBuilder { + ObjectBuilder::new() + .property("id", uri("The identifier of the subject.")) + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .description(Some("A subject and the claims about it.")) + } + + /// One subject, or a non-empty array of them. + fn subjects(section: &str) -> OneOfBuilder { + one_of( + [ + subject().into(), + array(subject(), "") + .min_items(Some(1)) + .description(None::) + .into(), + ], + &format!("The subjects and the claims about them ({section})."), + ) + } + + /// An object with a `type`, such as a `credentialStatus` entry. + fn typed(description: &str) -> ObjectBuilder { + ObjectBuilder::new() + .property("id", uri("The identifier.")) + .property("type", string_or_strings("A type.", "The types.")) + .required("type") + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .description(Some(description)) + } + + /// One typed object, or an array of them. + fn typed_one_or_many(item: &str, description: &str) -> OneOfBuilder { + one_of( + [ + typed(item).into(), + array(typed(item), "").description(None::).into(), + ], + description, + ) + } + + /// A string, a language value object, or an array of language value objects (VCDM 2.0 + /// §11.1). + fn language_value(description: &str) -> ObjectBuilder { + ObjectBuilder::new() + .schema_type(SchemaType::AnyValue) + .description(Some(description)) + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl PartialSchema for Credential { + fn schema() -> RefOr { + ObjectBuilder::new() + .description(Some( + "A verifiable credential of the VC Data Model 1.1 (§4), as the `vc` claim \ + of a JWT carries it once `iss`, `jti`, `nbf`, `exp` and `sub` are mapped \ + back.", + )) + .property("@context", context(CONTEXT_V1, "VCDM 1.1 §4.1")) + .required("@context") + .property("id", uri("The identifier of the credential (VCDM 1.1 §4.2).")) + .property("type", types(Credential::TYPE, "VCDM 1.1 §4.3")) + .required("type") + .property("issuer", party("The issuer (VCDM 1.1 §4.5).")) + .required("issuer") + .property( + "issuanceDate", + date_time("When the credential becomes valid (VCDM 1.1 §4.6)."), + ) + .required("issuanceDate") + .property( + "expirationDate", + date_time("When the credential ceases to be valid (VCDM 1.1 §4.8)."), + ) + .property("credentialSubject", subjects("VCDM 1.1 §4.4")) + .required("credentialSubject") + .property( + "credentialStatus", + typed("Where to learn whether the credential is revoked or suspended (VCDM 1.1 §4.9)."), + ) + .property( + "credentialSchema", + typed_one_or_many("A data schema.", "Data schemas (VCDM 1.1 §5.4)."), + ) + .property( + "refreshService", + typed_one_or_many("A refresh service.", "Refresh services (VCDM 1.1 §5.6)."), + ) + .property( + "termsOfUse", + typed_one_or_many("Terms of use.", "Terms of use (VCDM 1.1 §5.7)."), + ) + .property( + "evidence", + typed_one_or_many("Evidence.", "Evidence (VCDM 1.1 §5.8)."), + ) + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .into() + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl ToSchema for Credential { + fn name() -> Cow<'static, str> { + Cow::Borrowed("Credential") + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl PartialSchema for Presentation { + fn schema() -> RefOr { + ObjectBuilder::new() + .description(Some( + "A verifiable presentation of the VC Data Model 1.1 (§4.10), as the `vp` \ + claim of a JWT carries it once `iss` and `jti` are mapped back.", + )) + .property("@context", context(CONTEXT_V1, "VCDM 1.1 §4.1")) + .required("@context") + .property( + "id", + uri("The identifier of the presentation (VCDM 1.1 §4.2)."), + ) + .property("type", types(Presentation::TYPE, "VCDM 1.1 §4.3")) + .required("type") + .property( + "verifiableCredential", + array( + string("A credential JWT: a compact JWS.").pattern(Some(JWS_PATTERN)), + "The presented credentials, as JWTs (VCDM 1.1 §6.3.1).", + ), + ) + .property("holder", party("The holder presenting the credentials.")) + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .into() + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl ToSchema for Presentation { + fn name() -> Cow<'static, str> { + Cow::Borrowed("Presentation") + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl PartialSchema for CredentialV2 { + fn schema() -> RefOr { + ObjectBuilder::new() + .description(Some( + "A verifiable credential of the VC Data Model 2.0 (§4), the payload of a \ + `vc+jwt` (VC-JOSE-COSE §3.1.1).", + )) + .property("@context", context(CONTEXT_V2, "VCDM 2.0 §4.3")) + .required("@context") + .property("id", uri("The identifier of the credential (VCDM 2.0 §4.4).")) + .property("type", types(CredentialV2::TYPE, "VCDM 2.0 §4.5")) + .required("type") + .property( + "name", + language_value("A name for the credential (VCDM 2.0 §4.6)."), + ) + .property( + "description", + language_value("A description of the credential (VCDM 2.0 §4.6)."), + ) + .property("issuer", party("The issuer (VCDM 2.0 §4.7).")) + .required("issuer") + .property( + "validFrom", + date_time("When the credential becomes valid (VCDM 2.0 §4.9)."), + ) + .property( + "validUntil", + date_time("When the credential ceases to be valid (VCDM 2.0 §4.9)."), + ) + .property( + "credentialStatus", + typed_one_or_many( + "A status entry.", + "Where to learn whether the credential is revoked or suspended (VCDM 2.0 §4.10).", + ), + ) + .property("credentialSubject", subjects("VCDM 2.0 §4.8")) + .required("credentialSubject") + .property( + "credentialSchema", + typed_one_or_many("A data schema.", "Data schemas (VCDM 2.0 §4.11)."), + ) + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .into() + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl ToSchema for CredentialV2 { + fn name() -> Cow<'static, str> { + Cow::Borrowed("CredentialV2") + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl PartialSchema for PresentationV2 { + fn schema() -> RefOr { + ObjectBuilder::new() + .description(Some( + "A verifiable presentation of the VC Data Model 2.0 (§4.13), the payload of \ + a `vp+jwt` (VC-JOSE-COSE §3.1.2), carrying enveloped credentials.", + )) + .property("@context", context(CONTEXT_V2, "VCDM 2.0 §4.3")) + .required("@context") + .property( + "id", + uri("The identifier of the presentation (VCDM 2.0 §4.4)."), + ) + .property("type", types(PresentationV2::TYPE, "VCDM 2.0 §4.5")) + .required("type") + .property("holder", party("The holder presenting the credentials.")) + .property( + "verifiableCredential", + array( + schema_ref::(), + "The presented credentials, enveloped (VCDM 2.0 §4.13).", + ), + ) + .additional_properties(Some(AdditionalProperties::FreeForm(true))) + .into() + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl ToSchema for PresentationV2 { + fn name() -> Cow<'static, str> { + Cow::Borrowed("PresentationV2") + } + + fn schemas(schemas: &mut Schemas) { + collect::(schemas); + } + } + + /// An enveloped credential or presentation of `object_type`, secured as `media_type`. + fn enveloped(object_type: &str, media_type: &str, section: &str) -> RefOr { + ObjectBuilder::new() + .description(Some(format!( + "An `{object_type}` (VCDM 2.0 {section}): a secured object carried in a `data:` \ + URL, `{media_type}` for a JWT." + ))) + .property("@context", context(CONTEXT_V2, "VCDM 2.0 §4.3")) + .required("@context") + .property( + "id", + uri("The `data:` URL holding the secured form (RFC 2397).").pattern(Some("^data:")), + ) + .required("id") + .property("type", types(object_type, "VCDM 2.0 §4.5")) + .required("type") + .into() + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl PartialSchema for EnvelopedVc { + fn schema() -> RefOr { + enveloped(EnvelopedVc::TYPE, EnvelopedVc::JWT_MEDIA_TYPE, "§4.12.1") + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl ToSchema for EnvelopedVc { + fn name() -> Cow<'static, str> { + Cow::Borrowed("EnvelopedVc") + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl PartialSchema for EnvelopedVp { + fn schema() -> RefOr { + enveloped(EnvelopedVp::TYPE, EnvelopedVp::JWT_MEDIA_TYPE, "§4.13.1") + } + } + + #[cfg_attr(docsrs, doc(cfg(all(feature = "utoipa", feature = "vc"))))] + impl ToSchema for EnvelopedVp { + fn name() -> Cow<'static, str> { + Cow::Borrowed("EnvelopedVp") + } + } +} + // --------------------------------------------------------------------------------------------- // Claim paths // --------------------------------------------------------------------------------------------- diff --git a/tests/openapi.rs b/tests/openapi.rs index 1a02ffc..629dd39 100644 --- a/tests/openapi.rs +++ b/tests/openapi.rs @@ -1,11 +1,12 @@ //! The OpenAPI schemas of the `utoipa` feature accept the specifications' own examples (DID -//! Core documents, RFC 7517 keys, the DIF DID configuration resource, SD-JWT VC type -//! metadata) and the documents this crate produces, and reject what the types reject. +//! Core documents, RFC 7517 keys, the DIF DID configuration resource, VCDM 1.1 and 2.0 +//! credentials and presentations, SD-JWT VC type metadata) and the documents this crate +//! produces, and reject what the types reject. #![cfg(feature = "utoipa")] use boon::{Compiler, Draft, SchemaIndex, Schemas}; use identity_kit::did::{DidDocument, MethodRef, Service, VerificationMethod}; -use identity_kit::jose::Jwk; +use identity_kit::jose::{Jwk, JwsAlgorithm}; use serde_json::{Map, Value, json}; use utoipa::ToSchema; use utoipa::openapi::{RefOr, Schema}; @@ -24,10 +25,23 @@ fn openapi() -> Value { let mut schemas = Vec::new(); add::(&mut schemas); add::(&mut schemas); + add::(&mut schemas); #[cfg(feature = "domain-linkage")] add::(&mut schemas); #[cfg(feature = "vc")] add::(&mut schemas); + #[cfg(feature = "vc")] + { + use identity_kit::vc::{ + Credential, CredentialV2, EnvelopedVc, EnvelopedVp, Presentation, PresentationV2, + }; + add::(&mut schemas); + add::(&mut schemas); + add::(&mut schemas); + add::(&mut schemas); + add::(&mut schemas); + add::(&mut schemas); + } #[cfg(feature = "sd-jwt")] add::(&mut schemas); #[cfg(feature = "sd-jwt")] @@ -268,13 +282,24 @@ fn every_schema_compiles_with_its_references_resolved() { "MethodRef", "Service", "Jwk", + "JwsAlgorithm", ] { assert!(components.contains_key(name), "{name}"); } #[cfg(feature = "domain-linkage")] assert!(components.contains_key("DomainLinkageConfiguration")); #[cfg(feature = "vc")] - assert!(components.contains_key("ClaimPath")); + for name in [ + "ClaimPath", + "Credential", + "Presentation", + "CredentialV2", + "PresentationV2", + "EnvelopedVc", + "EnvelopedVp", + ] { + assert!(components.contains_key(name), "{name}"); + } #[cfg(feature = "sd-jwt")] for name in [ "TypeMetadata", @@ -327,10 +352,23 @@ fn every_schema_names_the_schemas_it_refers_to() { alone::(); alone::(); alone::(); + alone::(); #[cfg(feature = "domain-linkage")] alone::(); #[cfg(feature = "vc")] alone::(); + #[cfg(feature = "vc")] + { + use identity_kit::vc::{ + Credential, CredentialV2, EnvelopedVc, EnvelopedVp, Presentation, PresentationV2, + }; + alone::(); + alone::(); + alone::(); + alone::(); + alone::(); + alone::(); + } #[cfg(feature = "sd-jwt")] alone::(); #[cfg(feature = "sd-jwt")] @@ -662,3 +700,256 @@ fn status_type_schema() { validator.rejects(&rejected); } } + +#[test] +fn jws_algorithm_schema() { + let validator = Validator::of("JwsAlgorithm"); + for alg in JwsAlgorithm::ALL { + validator.accepts(&serde_json::to_string(alg).unwrap()); + } + // The symmetric and unsecured algorithms the type refuses. + for invalid in ["HS256", "none", "es256", ""] { + validator.rejects(&json!(invalid)); + } +} + +// --------------------------------------------------------------------------------------------- +// Verifiable credentials and presentations +// --------------------------------------------------------------------------------------------- + +/// VCDM 1.1 Example 1, with the `proof` as the example shows it. +#[cfg(feature = "vc")] +const VCDM_11_EXAMPLE_1: &str = r#"{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.edu/credentials/1872", + "type": ["VerifiableCredential", "AlumniCredential"], + "issuer": "https://example.edu/issuers/565049", + "issuanceDate": "2010-01-01T19:23:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "alumniOf": { + "id": "did:example:c276e12ec21ebfeb1f712ebc6f1", + "name": [{ + "value": "Example University", + "lang": "en" + }, { + "value": "Exemple d'Université", + "lang": "fr" + }] + } + }, + "proof": { + "type": "RsaSignature2018", + "created": "2017-06-18T21:19:10Z", + "proofPurpose": "assertionMethod", + "verificationMethod": "https://example.edu/issuers/565049#key-1", + "jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5XsITJX1CxPCT8yAV-TVkIEq_PbChOMqsLfRoPsnsgw5WEuts01mq-pQy7UJiN5mgRxD-WUcX16dUEMGlv50aqzpqh4Qktb3rk-BuQy72IFLOqV0G_zS245-kronKb78cPN25DGlcTwLtjPAYuNzVBAh4vGHSrQyHUdBBPM" + } +}"#; + +/// VCDM 1.1 Example 31 as a JWT (the credential inside Example 33). +#[cfg(feature = "vc")] +const EXAMPLE_31: &str = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDpleGFtcGxlOmFiZmUxM2Y3MTIxMjA0MzFjMjc2ZTEyZWNhYiNr\ + ZXlzLTEifQ.eyJzdWIiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJqdGkiOiJodHRwOi8vZXhhb\ + XBsZS5lZHUvY3JlZGVudGlhbHMvMzczMiIsImlzcyI6Imh0dHBzOi8vZXhhbXBsZS5jb20va2V5cy9mb28uandrIiwibmJmIjoxN\ + TQxNDkzNzI0LCJpYXQiOjE1NDE0OTM3MjQsImV4cCI6MTU3MzAyOTcyMywibm9uY2UiOiI2NjAhNjM0NUZTZXIiLCJ2YyI6eyJAY\ + 29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L\ + 2NyZWRlbnRpYWxzL2V4YW1wbGVzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJVbml2ZXJzaXR5RGVncmVlQ\ + 3JlZGVudGlhbCJdLCJjcmVkZW50aWFsU3ViamVjdCI6eyJkZWdyZWUiOnsidHlwZSI6IkJhY2hlbG9yRGVncmVlIiwibmFtZSI6I\ + jxzcGFuIGxhbmc9J2ZyLUNBJz5CYWNjYWxhdXLDqWF0IGVuIG11c2lxdWVzIG51bcOpcmlxdWVzPC9zcGFuPiJ9fX19.KLJo5GAy\ + BND3LDTn9H7FQokEsUEi8jKwXhGvoN3JtRa51xrNDgXDb0cq1UTYB-rK4Ft9YVmR1NI_ZOF8oGc_7wAp8PHbF2HaWodQIoOBxxT-\ + 4WNqAxft7ET6lkH-4S6Ux3rSGAmczMohEEf8eCeN-jC8WekdPl6zKZQj0YPB1rx6X0-xlFBs7cl6Wt8rfBP_tZ9YgVWrQmUWypSi\ + oc0MUyiphmyEbLZagTyPlUyflGlEdqrZAv6eSe6RtxJy6M1-lD7a5HTzanYTWBPAUHDZGyGKXdJw-W_x0IWChBzI8t3kpG253fg6\ + V3tPgHeKXE94fz_QpYfg--7kLsyBAfQGbg"; + +/// VC-JOSE-COSE "Securing JSON-LD Verifiable Credentials with JOSE": the credential. +#[cfg(feature = "vc")] +const VC_JOSE_COSE_CREDENTIAL: &str = r#"{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "id": "http://university.example/credentials/3732", + "type": ["VerifiableCredential", "ExampleDegreeCredential", "ExamplePersonCredential"], + "issuer": "https://university.example/issuers/14", + "validFrom": "2010-01-01T19:23:24Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "ExampleBachelorDegree", + "name": "Bachelor of Science and Arts" + }, + "alumniOf": { + "name": "Example University" + } + }, + "credentialSchema": [{ + "id": "https://example.org/examples/degree.json", + "type": "JsonSchema" + }, + { + "id": "https://example.org/examples/alumni.json", + "type": "JsonSchema" + }] +}"#; + +/// VC-JOSE-COSE "Securing JSON-LD Verifiable Presentations with JOSE": the presentation JWT, +/// whose payload holds an enveloped credential. +#[cfg(feature = "vc")] +const VC_JOSE_COSE_PRESENTATION_JWT: &str = + include_str!("vectors/vc_jose_cose/enveloped_presentation_jwt.txt"); + +#[cfg(feature = "vc")] +#[test] +fn vcdm_11_examples_validate() { + use identity_kit::jose::CompactJws; + use identity_kit::vc::{Credential, JwtClaims, Presentation}; + + let credentials = Validator::of("Credential"); + credentials.accepts(VCDM_11_EXAMPLE_1); + let credential: Credential = serde_json::from_str(VCDM_11_EXAMPLE_1).unwrap(); + credentials.accepts(&serde_json::to_string(&credential).unwrap()); + + // Example 31: the credential its JWT claims map back to. + let claims: JwtClaims = CompactJws::decode(EXAMPLE_31).unwrap().claims().unwrap(); + let credential = Credential::from_jwt_claims(&claims).unwrap(); + credentials.accepts(&serde_json::to_string(&credential).unwrap()); + + // Example 33: the `vp` claim, with Example 31 for its `"..."`, and the presentation the + // crate maps it to. + let presentations = Validator::of("Presentation"); + let vp = json!({ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": ["VerifiablePresentation", "CredentialManagerPresentation"], + "verifiableCredential": [EXAMPLE_31] + }); + presentations.accepts(&vp.to_string()); + let claims: JwtClaims = serde_json::from_value(json!({ + "iss": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "jti": "urn:uuid:3978344f-8596-4c3a-a978-8fcaba3903c5", + "vp": vp + })) + .unwrap(); + let presentation = Presentation::from_jwt_claims(&claims).unwrap(); + presentations.accepts(&serde_json::to_string(&presentation).unwrap()); +} + +#[cfg(feature = "vc")] +#[test] +fn vcdm_20_examples_validate() { + use identity_kit::jose::CompactJws; + use identity_kit::vc::{CredentialV2, EnvelopedVp, Jwt, PresentationV2}; + + let credentials = Validator::of("CredentialV2"); + credentials.accepts(VC_JOSE_COSE_CREDENTIAL); + let credential: CredentialV2 = serde_json::from_str(VC_JOSE_COSE_CREDENTIAL).unwrap(); + credentials.accepts(&serde_json::to_string(&credential).unwrap()); + + // The presentation payload, its enveloped credential, and the credential inside it. + let jws = CompactJws::decode(VC_JOSE_COSE_PRESENTATION_JWT.trim()).unwrap(); + let payload: Value = jws.claims().unwrap(); + Validator::of("PresentationV2").accepts(&payload.to_string()); + let presentation: PresentationV2 = serde_json::from_value(payload.clone()).unwrap(); + Validator::of("PresentationV2").accepts(&serde_json::to_string(&presentation).unwrap()); + + // The envelope as published, whose `data:` URL repeats the credential JWT after a `;` + // (`tests/vc_v2.rs` reads the first copy). + let envelopes = Validator::of("EnvelopedVc"); + for envelope in payload["verifiableCredential"].as_array().unwrap() { + envelopes.accepts(&envelope.to_string()); + } + + // The presentation JWT, enveloped in turn. + let presentation_envelope = + EnvelopedVp::from_jwt(&Jwt::new(VC_JOSE_COSE_PRESENTATION_JWT.trim()).unwrap()); + Validator::of("EnvelopedVp").accepts(&serde_json::to_string(&presentation_envelope).unwrap()); +} + +#[cfg(feature = "vc")] +#[test] +fn credentials_the_types_reject_are_rejected() { + let example: Value = serde_json::from_str(VCDM_11_EXAMPLE_1).unwrap(); + let without = |member: &str| { + let mut value = example.clone(); + value.as_object_mut().unwrap().remove(member); + value + }; + let with = |member: &str, replacement: Value| { + let mut value = example.clone(); + value[member] = replacement; + value + }; + let credentials = Validator::of("Credential"); + for invalid in [ + without("@context"), + without("type"), + without("issuer"), + without("issuanceDate"), + without("credentialSubject"), + // A context that does not begin with the VCDM 1.1 one. + with("@context", json!("https://www.w3.org/ns/credentials/v2")), + with( + "@context", + json!([ + "https://www.w3.org/2018/credentials/examples/v1", + "https://www.w3.org/2018/credentials/v1" + ]), + ), + with("@context", json!([])), + // An issuer object without `id`, or not a string or object. + with("issuer", json!({ "name": "Example University" })), + with("issuer", json!(42)), + // No subject. + with("credentialSubject", json!([])), + // A status entry without `type`. + with( + "credentialStatus", + json!({ "id": "https://example.edu/status/24" }), + ), + ] { + credentials.rejects(&invalid); + } + + // VCDM 2.0: the 1.1 context, no subject, no issuer. + let example: Value = serde_json::from_str(VC_JOSE_COSE_CREDENTIAL).unwrap(); + let credentials = Validator::of("CredentialV2"); + let mut v1 = example.clone(); + v1["@context"] = json!(["https://www.w3.org/2018/credentials/v1"]); + credentials.rejects(&v1); + for member in ["credentialSubject", "issuer", "type"] { + let mut value = example.clone(); + value.as_object_mut().unwrap().remove(member); + credentials.rejects(&value); + } + + // A VCDM 1.1 presentation carries credentials as JWTs. + let presentations = Validator::of("Presentation"); + presentations.rejects(&json!({ + "@context": "https://www.w3.org/2018/credentials/v1", + "type": "VerifiablePresentation", + "verifiableCredential": [example] + })); + presentations.rejects(&json!({ + "@context": "https://www.w3.org/2018/credentials/v1", + "type": "VerifiablePresentation", + "verifiableCredential": ["not a JWT"] + })); + + // An envelope's `id` is a `data:` URL, and it has every member. + let envelopes = Validator::of("EnvelopedVc"); + envelopes.rejects(&json!({ + "@context": "https://www.w3.org/ns/credentials/v2", + "id": "https://example.com/credential", + "type": "EnvelopedVerifiableCredential" + })); + envelopes.rejects(&json!({ + "@context": "https://www.w3.org/ns/credentials/v2", + "type": "EnvelopedVerifiableCredential" + })); +}