From a70bdf0cce29be0761538741cd02d4f796406300 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Tue, 28 Oct 2025 10:20:01 +0100 Subject: [PATCH 1/8] VC Data Model 2.0 first draft --- examples/0_basic/9_vc_v2.rs | 95 ++++++ examples/Cargo.toml | 4 + identity_credential/src/credential/builder.rs | 35 ++- .../src/credential/credential.rs | 65 +++- .../src/credential/credential_v2.rs | 287 ++++++++++++++++++ .../src/credential/jwt_serialization.rs | 131 +++++++- identity_credential/src/credential/mod.rs | 40 +++ .../decoded_jwt_credential.rs | 14 + .../jwt_credential_validator.rs | 159 +++++++++- .../jwt_credential_validator_hybrid.rs | 6 +- .../jwt_credential_validator_utils.rs | 201 +++++++----- .../src/validator/sd_jwt/validator.rs | 6 +- .../packages/iota_identity/Move.lock | 8 +- .../src/storage/jwk_document_ext.rs | 11 +- 14 files changed, 931 insertions(+), 131 deletions(-) create mode 100644 examples/0_basic/9_vc_v2.rs create mode 100644 identity_credential/src/credential/credential_v2.rs diff --git a/examples/0_basic/9_vc_v2.rs b/examples/0_basic/9_vc_v2.rs new file mode 100644 index 0000000000..0bd9f27014 --- /dev/null +++ b/examples/0_basic/9_vc_v2.rs @@ -0,0 +1,95 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! This example shows how to create a Verifiable Credential and validate it. +//! In this example, alice takes the role of the subject, while we also have an issuer. +//! The issuer signs a UniversityDegreeCredential type verifiable credential with Alice's name and DID. +//! This Verifiable Credential can be verified by anyone, allowing Alice to take control of it and share it with +//! whomever they please. +//! +//! cargo run --release --example 9_vc_v2 + +use examples::create_did_document; +use examples::get_funded_client; +use examples::get_memstorage; +use identity_eddsa_verifier::EdDSAJwsVerifier; +use identity_iota::core::Object; + +use identity_iota::credential::DecodedJwtCredentialV2; +use identity_iota::credential::Jwt; +use identity_iota::credential::JwtCredentialValidationOptions; +use identity_iota::credential::JwtCredentialValidator; +use identity_iota::storage::JwkDocumentExt; +use identity_iota::storage::JwsSignatureOptions; + +use identity_iota::core::json; +use identity_iota::core::FromJson; +use identity_iota::core::Url; +use identity_iota::credential::credential_v2::Credential; +use identity_iota::credential::CredentialBuilder; +use identity_iota::credential::FailFast; +use identity_iota::credential::Subject; +use identity_iota::did::DID; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // create new issuer account with did document + let issuer_storage = get_memstorage()?; + let issuer_identity_client = get_funded_client(&issuer_storage).await?; + let (issuer_document, issuer_vm_fragment) = create_did_document(&issuer_identity_client, &issuer_storage).await?; + + // create new holder account with did document + let holder_storage = get_memstorage()?; + let holder_identity_client = get_funded_client(&holder_storage).await?; + let (holder_document, _) = create_did_document(&holder_identity_client, &holder_storage).await?; + + // Create a credential subject indicating the degree earned by Alice. + let subject: Subject = Subject::from_json_value(json!({ + "id": holder_document.id().as_str(), + "name": "Alice", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts", + }, + "GPA": "4.0", + }))?; + + // Build credential using subject above and issuer. + let credential: Credential = CredentialBuilder::default() + .id(Url::parse("https://example.edu/credentials/3732")?) + .issuer(Url::parse(issuer_document.id().as_str())?) + .type_("UniversityDegreeCredential") + .subject(subject) + .build_v2()?; + + let credential_jwt: Jwt = issuer_document + .create_credential_jwt( + &credential, + &issuer_storage, + &issuer_vm_fragment, + &JwsSignatureOptions::default(), + None, + ) + .await?; + + // Before sending this credential to the holder the issuer wants to validate that some properties + // of the credential satisfy their expectations. + + // Validate the credential's signature using the issuer's DID Document, the credential's semantic structure, + // that the issuance date is not in the future and that the expiration date is not in the past: + let decoded_credential: DecodedJwtCredentialV2 = + JwtCredentialValidator::with_signature_verifier(EdDSAJwsVerifier::default()) + .validate_v2::<_, Object>( + &credential_jwt, + &issuer_document, + &JwtCredentialValidationOptions::default(), + FailFast::FirstError, + ) + .unwrap(); + + println!("VC successfully validated"); + + println!("Credential JSON > {:#}", decoded_credential.credential); + + Ok(()) +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml index a967b5eb80..e4ead39e67 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -82,6 +82,10 @@ name = "7_revoke_vc" path = "0_basic/8_legacy_stronghold.rs" name = "8_legacy_stronghold" +[[example]] +path = "0_basic/9_vc_v2.rs" +name = "9_vc_v2" + [[example]] path = "1_advanced/0_did_controls_did.rs" name = "0_did_controls_did" diff --git a/identity_credential/src/credential/builder.rs b/identity_credential/src/credential/builder.rs index f95771c500..1f5ce5f2f6 100644 --- a/identity_credential/src/credential/builder.rs +++ b/identity_credential/src/credential/builder.rs @@ -7,6 +7,7 @@ use identity_core::common::Timestamp; use identity_core::common::Url; use identity_core::common::Value; +use crate::credential::credential_v2::Credential as CredentialV2; use crate::credential::Credential; use crate::credential::Evidence; use crate::credential::Issuer; @@ -20,7 +21,7 @@ use crate::error::Result; use super::Proof; /// A `CredentialBuilder` is used to create a customized `Credential`. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct CredentialBuilder { pub(crate) context: Vec, pub(crate) id: Option, @@ -43,9 +44,9 @@ impl CredentialBuilder { /// Creates a new `CredentialBuilder`. pub fn new(properties: T) -> Self { Self { - context: vec![Credential::::base_context().clone()], + context: Vec::new(), id: None, - types: vec![Credential::::base_type().into()], + types: Vec::new(), subject: Vec::new(), issuer: None, issuance_date: None, @@ -119,6 +120,20 @@ impl CredentialBuilder { self } + /// Sets the value of the `Credential` `validFrom`. + #[must_use] + pub fn valid_from(mut self, value: Timestamp) -> Self { + self.issuance_date = Some(value); + self + } + + /// Sets the value of the `Credential` `validUntil`. + #[must_use] + pub fn valid_until(mut self, value: Timestamp) -> Self { + self.expiration_date = Some(value); + self + } + /// Adds a value to the `credentialStatus` set. #[must_use] pub fn status(mut self, value: impl Into) -> Self { @@ -172,6 +187,11 @@ impl CredentialBuilder { pub fn build(self) -> Result> { Credential::from_builder(self) } + + /// Returns a new [CredentialV2] based on the builder's configuration. + pub fn build_v2(self) -> Result> { + CredentialV2::from_builder(self) + } } impl CredentialBuilder { @@ -201,15 +221,6 @@ impl CredentialBuilder { } } -impl Default for CredentialBuilder -where - T: Default, -{ - fn default() -> Self { - Self::new(T::default()) - } -} - #[cfg(test)] mod tests { use identity_core::common::Object; diff --git a/identity_credential/src/credential/credential.rs b/identity_credential/src/credential/credential.rs index bba4dc69ce..86ee605fc3 100644 --- a/identity_credential/src/credential/credential.rs +++ b/identity_credential/src/credential/credential.rs @@ -19,6 +19,8 @@ use identity_core::common::Url; use identity_core::convert::FmtJson; use crate::credential::CredentialBuilder; +use crate::credential::CredentialSealed; +use crate::credential::CredentialT; use crate::credential::Evidence; use crate::credential::Issuer; use crate::credential::Policy; @@ -104,7 +106,15 @@ impl Credential { } /// Returns a new `Credential` based on the `CredentialBuilder` configuration. - pub fn from_builder(builder: CredentialBuilder) -> Result { + pub fn from_builder(mut builder: CredentialBuilder) -> Result { + if builder.context.first() != Some(Self::base_context()) { + builder.context.insert(0, Self::base_context().clone()); + } + + if builder.types.first().map(String::as_str) != Some(Self::base_type()) { + builder.types.insert(0, Self::base_type().to_owned()); + } + let this: Self = Self { context: OneOrMany::Many(builder.context), id: builder.id, @@ -197,6 +207,59 @@ where } } +impl CredentialSealed for Credential {} + +impl CredentialT for Credential +where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, +{ + type Properties = T; + + fn base_context(&self) -> &'static Context { + Self::base_context() + } + + fn type_(&self) -> &OneOrMany { + &self.types + } + + fn context(&self) -> &OneOrMany { + &self.context + } + + fn subject(&self) -> &OneOrMany { + &self.credential_subject + } + + fn issuer(&self) -> &Issuer { + &self.issuer + } + + fn valid_from(&self) -> Timestamp { + self.issuance_date + } + + fn valid_until(&self) -> Option { + self.expiration_date + } + + fn properties(&self) -> &Self::Properties { + &self.properties + } + + fn status(&self) -> Option<&Status> { + self.credential_status.as_ref() + } + + fn non_transferable(&self) -> bool { + self.non_transferable.unwrap_or_default() + } + + fn serialize_jwt(&self, custom_claims: Option) -> Result { + self.serialize_jwt(custom_claims) + } +} + #[cfg(test)] mod tests { use identity_core::common::OneOrMany; diff --git a/identity_credential/src/credential/credential_v2.rs b/identity_credential/src/credential/credential_v2.rs new file mode 100644 index 0000000000..b2ca215d0f --- /dev/null +++ b/identity_credential/src/credential/credential_v2.rs @@ -0,0 +1,287 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use identity_core::common::Context; +use identity_core::common::Object; +use identity_core::common::OneOrMany; +use identity_core::common::Timestamp; +use identity_core::common::Url; +use identity_core::convert::FmtJson as _; +use identity_core::convert::ToJson as _; +use once_cell::sync::Lazy; +use serde::de::DeserializeOwned; +use serde::de::Error as _; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +use crate::credential::CredentialBuilder; +use crate::credential::CredentialJwtClaims; +use crate::credential::CredentialSealed; +use crate::credential::CredentialT; +use crate::credential::Evidence; +use crate::credential::Issuer; +use crate::credential::Policy; +use crate::credential::Proof; +use crate::credential::RefreshService; +use crate::credential::Schema; +use crate::credential::Status; +use crate::credential::Subject; +use crate::error::Error; +use crate::error::Result; + +pub(crate) static BASE_CONTEXT: Lazy = + Lazy::new(|| Context::Url(Url::parse("https://www.w3.org/ns/credentials/v2").unwrap())); + +fn deserialize_vc2_0_context<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let ctx = OneOrMany::::deserialize(deserializer)?; + if ctx.contains(&BASE_CONTEXT) { + Ok(ctx) + } else { + Err(D::Error::custom("Missing base context")) + } +} + +/// A [VC Data Model](https://www.w3.org/TR/vc-data-model-2.0/) 2.0 Verifiable Credential. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct Credential { + /// The JSON-LD context(s) applicable to the `Credential`. + #[serde(rename = "@context", deserialize_with = "deserialize_vc2_0_context")] + pub context: OneOrMany, + /// A unique `URI` that may be used to identify the `Credential`. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// One or more URIs defining the type of the `Credential`. + #[serde(rename = "type")] + pub types: OneOrMany, + /// One or more `Object`s representing the `Credential` subject(s). + #[serde(rename = "credentialSubject")] + pub credential_subject: OneOrMany, + /// A reference to the issuer of the `Credential`. + pub issuer: Issuer, + /// A timestamp of when the `Credential` becomes valid. + #[serde(rename = "validFrom")] + pub valid_from: Timestamp, + /// A timestamp of when the `Credential` should no longer be considered valid. + #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")] + pub valid_until: Option, + /// Information used to determine the current status of the `Credential`. + #[serde(default, rename = "credentialStatus", skip_serializing_if = "Option::is_none")] + pub credential_status: Option, + /// Information used to assist in the enforcement of a specific `Credential` structure. + #[serde(default, rename = "credentialSchema", skip_serializing_if = "OneOrMany::is_empty")] + pub credential_schema: OneOrMany, + /// Service(s) used to refresh an expired `Credential`. + #[serde(default, rename = "refreshService", skip_serializing_if = "OneOrMany::is_empty")] + pub refresh_service: OneOrMany, + /// Terms-of-use specified by the `Credential` issuer. + #[serde(default, rename = "termsOfUse", skip_serializing_if = "OneOrMany::is_empty")] + pub terms_of_use: OneOrMany, + /// Human-readable evidence used to support the claims within the `Credential`. + #[serde(default, skip_serializing_if = "OneOrMany::is_empty")] + pub evidence: OneOrMany, + /// Indicates that the `Credential` must only be contained within a + /// [`Presentation`][crate::presentation::Presentation] with a proof issued from the `Credential` subject. + #[serde(rename = "nonTransferable", skip_serializing_if = "Option::is_none")] + pub non_transferable: Option, + /// Miscellaneous properties. + #[serde(flatten)] + pub properties: T, + /// Optional cryptographic proof, unrelated to JWT. + #[serde(skip_serializing_if = "Option::is_none")] + pub proof: Option, +} + +impl Credential { + /// Returns the base context for `Credential`s. + pub fn base_context() -> &'static Context { + &BASE_CONTEXT + } + + /// Returns the base type for `Credential`s. + pub fn base_type() -> &'static str { + "VerifiableCredential" + } + + /// Creates a `Credential` from a `CredentialBuilder`. + pub fn from_builder(mut builder: CredentialBuilder) -> Result { + if builder.context.first() != Some(Self::base_context()) { + builder.context.insert(0, Self::base_context().clone()); + } + + if builder.types.first().map(String::as_str) != Some(Self::base_type()) { + builder.types.insert(0, Self::base_type().to_owned()); + } + + let this = Self { + context: OneOrMany::Many(builder.context), + id: builder.id, + types: builder.types.into(), + credential_subject: builder.subject.into(), + issuer: builder.issuer.ok_or(Error::MissingIssuer)?, + valid_from: builder.issuance_date.unwrap_or_default(), + valid_until: builder.expiration_date, + credential_status: builder.status, + credential_schema: builder.schema.into(), + refresh_service: builder.refresh_service.into(), + terms_of_use: builder.terms_of_use.into(), + evidence: builder.evidence.into(), + non_transferable: builder.non_transferable, + properties: builder.properties, + proof: builder.proof, + }; + + this.check_structure()?; + + Ok(this) + } + + /// Validates the semantic structure of the `Credential`. + pub(crate) fn check_structure(&self) -> Result<()> { + // Ensure the base context is present and in the correct location + match self.context.get(0) { + Some(context) if context == Self::base_context() => {} + Some(_) | None => return Err(Error::MissingBaseContext), + } + + // The set of types MUST contain the base type + if !self.types.iter().any(|type_| type_ == Self::base_type()) { + return Err(Error::MissingBaseType); + } + + // Credentials MUST have at least one subject + if self.credential_subject.is_empty() { + return Err(Error::MissingSubject); + } + + // Each subject is defined as one or more properties - no empty objects + for subject in self.credential_subject.iter() { + if subject.id.is_none() && subject.properties.is_empty() { + return Err(Error::InvalidSubject); + } + } + + Ok(()) + } +} + +impl Display for Credential +where + T: Serialize, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> core::fmt::Result { + self.fmt_json(f) + } +} + +impl CredentialSealed for Credential {} + +impl CredentialT for Credential +where + T: Clone + Serialize + DeserializeOwned, +{ + type Properties = T; + + fn base_context(&self) -> &'static Context { + Self::base_context() + } + + fn type_(&self) -> &OneOrMany { + &self.types + } + + fn context(&self) -> &OneOrMany { + &self.context + } + + fn subject(&self) -> &OneOrMany { + &self.credential_subject + } + + fn issuer(&self) -> &Issuer { + &self.issuer + } + + fn valid_from(&self) -> Timestamp { + self.valid_from + } + + fn valid_until(&self) -> Option { + self.valid_until + } + + fn properties(&self) -> &Self::Properties { + &self.properties + } + + fn status(&self) -> Option<&Status> { + self.credential_status.as_ref() + } + + fn non_transferable(&self) -> bool { + self.non_transferable.unwrap_or_default() + } + + fn serialize_jwt(&self, custom_claims: Option) -> Result { + self.serialize_jwt(custom_claims) + } +} + +impl Credential +where + T: ToOwned + Serialize + DeserializeOwned, +{ + /// Serializes the [`Credential`] as a JWT claims set + /// in accordance with [VC Data Model v2.0](https://www.w3.org/TR/vc-data-model-2.0/). + /// + /// The resulting string can be used as the payload of a JWS when issuing the credential. + pub fn serialize_jwt(&self, custom_claims: Option) -> Result { + let jwt_representation: CredentialJwtClaims<'_, T> = CredentialJwtClaims::new_v2(self, custom_claims)?; + jwt_representation + .to_json() + .map_err(|err| Error::JwtClaimsSetSerializationError(err.into())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_from_json_str() { + let json_credential = 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" + ], + "issuer": "https://university.example/issuers/565049", + "validFrom": "2010-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "ExampleBachelorDegree", + "name": "Bachelor of Science and Arts" + } + } +} + "#; + serde_json::from_str::(json_credential).expect("valid VC using Data Model 2.0"); + } + + #[test] + fn invalid_from_json_str() { + let json_credential = include_str!("../../tests/fixtures/credential-1.json"); + let _error = serde_json::from_str::(json_credential).unwrap_err(); + } +} diff --git a/identity_credential/src/credential/jwt_serialization.rs b/identity_credential/src/credential/jwt_serialization.rs index e763a53858..ddb7fed1f1 100644 --- a/identity_credential/src/credential/jwt_serialization.rs +++ b/identity_credential/src/credential/jwt_serialization.rs @@ -15,6 +15,7 @@ use identity_core::common::Timestamp; use identity_core::common::Url; use serde::de::DeserializeOwned; +use crate::credential::credential_v2::Credential as CredentialV2; use crate::credential::Credential; use crate::credential::Evidence; use crate::credential::Issuer; @@ -115,6 +116,59 @@ where credential_subject: InnerCredentialSubject::new(subject), issuance_date: None, expiration_date: None, + valid_from: None, + valid_until: None, + issuer: None, + credential_schema: Cow::Borrowed(credential_schema), + credential_status: credential_status.as_ref().map(Cow::Borrowed), + refresh_service: Cow::Borrowed(refresh_service), + terms_of_use: Cow::Borrowed(terms_of_use), + evidence: Cow::Borrowed(evidence), + non_transferable: *non_transferable, + properties: Cow::Borrowed(properties), + proof: proof.as_ref().map(Cow::Borrowed), + }, + custom, + }) + } + + pub(crate) fn new_v2(credential: &'credential CredentialV2, custom: Option) -> Result { + let CredentialV2 { + context, + id, + types, + credential_subject: OneOrMany::One(subject), + issuer, + valid_from, + valid_until, + credential_status, + credential_schema, + refresh_service, + terms_of_use, + evidence, + non_transferable, + properties, + proof, + } = credential + else { + return Err(Error::MoreThanOneSubjectInJwt); + }; + + Ok(Self { + exp: valid_until.map(|value| Timestamp::to_unix(&value)), + iss: Cow::Borrowed(issuer), + issuance_date: IssuanceDateClaims::new(*valid_from), + jti: id.as_ref().map(Cow::Borrowed), + sub: subject.id.as_ref().map(Cow::Borrowed), + vc: InnerCredential { + context: Cow::Borrowed(context), + id: None, + types: Cow::Borrowed(types), + credential_subject: InnerCredentialSubject::new(subject), + issuance_date: None, + expiration_date: None, + valid_from: None, + valid_until: None, issuer: None, credential_schema: Cow::Borrowed(credential_schema), credential_status: credential_status.as_ref().map(Cow::Borrowed), @@ -213,12 +267,11 @@ where jti, sub, vc, - custom: _, + .. } = self; let InnerCredential { context, - id: _, types, credential_subject, credential_status, @@ -229,9 +282,7 @@ where non_transferable, properties, proof, - issuance_date: _, - issuer: _, - expiration_date: _, + .. } = vc; Ok(Credential { @@ -260,6 +311,70 @@ where proof: proof.map(Cow::into_owned), }) } + + /// Converts the JWT representation into a [`CredentialV2`]. + /// + /// # Errors + /// Errors if either timestamp conversion or [`Self::check_consistency`] fails. + pub(crate) fn try_into_credential_v2(self) -> Result> { + self.check_consistency()?; + + let Self { + exp, + iss, + issuance_date, + jti, + sub, + vc, + .. + } = self; + + let InnerCredential { + context, + types, + credential_subject, + credential_status, + credential_schema, + refresh_service, + terms_of_use, + evidence, + non_transferable, + properties, + proof, + .. + } = vc; + + // Make sure inner credential contains the right context + if context.first() != Some(&crate::credential::credential_v2::BASE_CONTEXT) { + return Err(Error::MissingBaseContext); + } + + Ok(CredentialV2 { + context: context.into_owned(), + id: jti.map(Cow::into_owned), + types: types.into_owned(), + credential_subject: { + OneOrMany::One(Subject { + id: sub.map(Cow::into_owned), + properties: credential_subject.properties.into_owned(), + }) + }, + issuer: iss.into_owned(), + valid_from: issuance_date.to_issuance_date()?, + valid_until: exp + .map(Timestamp::from_unix) + .transpose() + .map_err(|_| Error::TimestampConversionError)?, + credential_status: credential_status.map(Cow::into_owned), + credential_schema: credential_schema.into_owned(), + refresh_service: refresh_service.into_owned(), + terms_of_use: terms_of_use.into_owned(), + evidence: evidence.into_owned(), + non_transferable, + properties: properties.into_owned(), + proof: proof.map(Cow::into_owned), + }) + } } /// The [VC Data Model v1.1](https://www.w3.org/TR/vc-data-model/#json-web-token) states that issuanceDate @@ -348,6 +463,12 @@ where /// A timestamp of when the `Credential` should no longer be considered valid. #[serde(rename = "expirationDate", skip_serializing_if = "Option::is_none")] expiration_date: Option, + /// A timestamp of when the `Credential` becomes valid. + #[serde(rename = "validFrom", skip_serializing_if = "Option::is_none")] + valid_from: Option, + /// A timestamp of when the `Credential` should no longer be considered valid. + #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")] + valid_until: Option, /// Information used to determine the current status of the `Credential`. #[serde(default, rename = "credentialStatus", skip_serializing_if = "Option::is_none")] credential_status: Option>, diff --git a/identity_credential/src/credential/mod.rs b/identity_credential/src/credential/mod.rs index 3d7422c83b..f5d75ba6e8 100644 --- a/identity_credential/src/credential/mod.rs +++ b/identity_credential/src/credential/mod.rs @@ -7,6 +7,8 @@ mod builder; mod credential; +/// VC Data Model 2.0 implementation. +pub mod credential_v2; mod evidence; mod issuer; #[cfg(feature = "jpt-bbs-plus")] @@ -27,6 +29,11 @@ mod schema; mod status; mod subject; +use identity_core::common::Context; +use identity_core::common::Object; +use identity_core::common::OneOrMany; +use identity_core::common::Timestamp; + pub use self::builder::CredentialBuilder; pub use self::credential::Credential; pub use self::evidence::Evidence; @@ -55,3 +62,36 @@ pub use self::subject::Subject; pub(crate) use self::jwt_serialization::CredentialJwtClaims; #[cfg(feature = "presentation")] pub(crate) use self::jwt_serialization::IssuanceDateClaims; + +trait CredentialSealed {} + +/// A VerifiableCredential type. This trait is implemented for [Credential] +/// and for [CredentialV2](credential_v2::Credential). +#[allow(private_bounds)] +pub trait CredentialT: CredentialSealed { + /// The type of the custom claims. + type Properties; + + /// The Credential's context. + fn context(&self) -> &OneOrMany; + /// The Credential's types. + fn type_(&self) -> &OneOrMany; + /// The Credential's subjects. + fn subject(&self) -> &OneOrMany; + /// The Credential's issuer. + fn issuer(&self) -> &Issuer; + /// The Credential's issuance date. + fn valid_from(&self) -> Timestamp; + /// The Credential's expiration date, if any. + fn valid_until(&self) -> Option; + /// The Credential's validity status, if any. + fn status(&self) -> Option<&Status>; + /// The Credential's custom properties. + fn properties(&self) -> &Self::Properties; + /// Whether the Credential's `nonTransferable` property is set. + fn non_transferable(&self) -> bool; + /// The Credential's base context. + fn base_context(&self) -> &'static Context; + /// Serializes this credential as a JWT payload encoded string. + fn serialize_jwt(&self, custom_claims: Option) -> Result; +} diff --git a/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs b/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs index d2daacd4c2..be1619a99a 100644 --- a/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs +++ b/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs @@ -1,6 +1,7 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use crate::credential::credential_v2::Credential as CredentialV2; use crate::credential::Credential; use identity_core::common::Object; use identity_verification::jose::jws::JwsHeader; @@ -19,3 +20,16 @@ pub struct DecodedJwtCredential { /// The custom claims parsed from the JWT. pub custom_claims: Option, } + +/// Decoded [`CredentialV2`] from a cryptographically verified JWS. +/// +/// Note that having an instance of this type only means the JWS it was constructed from was verified. +/// It does not imply anything about a potentially present proof property on the credential itself. +pub struct DecodedJwtCredentialV2 { + /// The decoded credential parsed to the [Verifiable Credentials Data model](https://www.w3.org/TR/vc-data-model/). + pub credential: CredentialV2, + /// The protected header parsed from the JWS. + pub header: Box, + /// The custom claims parsed from the JWT. + pub custom_claims: Option, +} diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs index acaa991e45..afc4f14b30 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs @@ -1,6 +1,8 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::str::FromStr as _; + use identity_core::convert::FromJson; use identity_did::CoreDID; use identity_did::DIDUrl; @@ -20,7 +22,9 @@ use super::JwtValidationError; use super::SignerContext; use crate::credential::Credential; use crate::credential::CredentialJwtClaims; +use crate::credential::CredentialT; use crate::credential::Jwt; +use crate::validator::DecodedJwtCredentialV2; use crate::validator::FailFast; /// A type for decoding and validating [`Credential`]s. @@ -65,7 +69,7 @@ impl JwtCredentialValidator { fail_fast: FailFast, ) -> Result, CompoundCredentialValidationError> where - T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + T: Clone + serde::Serialize + serde::de::DeserializeOwned, DOC: AsRef, { let credential_token = self @@ -79,11 +83,68 @@ impl JwtCredentialValidator { })?; Self::validate_decoded_credential::( - credential_token, + &credential_token.credential, std::slice::from_ref(issuer.as_ref()), options, fail_fast, + )?; + + Ok(credential_token) + } + + /// Decodes and validates a [CredentialV2](crate::credential::credential_v2::Credential) issued as a JWT. + /// A [`DecodedJwtCredentialV2`] is returned upon success. + /// + /// The following properties are validated according to `options`: + /// - the issuer's signature on the JWS, + /// - the expiration date, + /// - the issuance date, + /// - the semantic structure. + /// + /// # Warning + /// The lack of an error returned from this method is in of itself not enough to conclude that the credential can be + /// trusted. This section contains more information on additional checks that should be carried out before and after + /// calling this method. + /// + /// ## The state of the issuer's DID Document + /// The caller must ensure that `issuer` represents an up-to-date DID Document. + /// + /// ## Properties that are not validated + /// There are many properties defined in [The Verifiable Credentials Data Model](https://www.w3.org/TR/vc-data-model/) that are **not** validated, such as: + /// `proof`, `credentialStatus`, `type`, `credentialSchema`, `refreshService` **and more**. + /// These should be manually checked after validation, according to your requirements. + /// + /// # Errors + /// An error is returned whenever a validated condition is not satisfied. + pub fn validate_v2( + &self, + credential_jwt: &Jwt, + issuer: &DOC, + options: &JwtCredentialValidationOptions, + fail_fast: FailFast, + ) -> Result, CompoundCredentialValidationError> + where + T: Clone + serde::Serialize + serde::de::DeserializeOwned, + DOC: AsRef, + { + let credential_token = Self::verify_signature_with_verifier_v2( + &self.0, + credential_jwt, + std::slice::from_ref(issuer.as_ref()), + &options.verification_options, ) + .map_err(|err| CompoundCredentialValidationError { + validation_errors: [err].into(), + })?; + + Self::validate_decoded_credential( + &credential_token.credential, + std::slice::from_ref(issuer), + options, + fail_fast, + )?; + + Ok(credential_token) } /// Decode and verify the JWS signature of a [`Credential`] issued as a JWT using the DID Document of a trusted @@ -119,21 +180,20 @@ impl JwtCredentialValidator { // validation. It also validates the relationship between a holder and the credential subjects when // `relationship_criterion` is Some. pub(crate) fn validate_decoded_credential( - credential_token: DecodedJwtCredential, + credential: &impl CredentialT, issuers: &[DOC], options: &JwtCredentialValidationOptions, fail_fast: FailFast, - ) -> Result, CompoundCredentialValidationError> + ) -> Result<(), CompoundCredentialValidationError> where - T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + T: Clone + serde::Serialize + serde::de::DeserializeOwned, DOC: AsRef, { - let credential: &Credential = &credential_token.credential; // Run all single concern Credential validations in turn and fail immediately if `fail_fast` is true. let expiry_date_validation = std::iter::once_with(|| { JwtCredentialValidatorUtils::check_expires_on_or_after( - &credential_token.credential, + credential, options.earliest_expiry_date.unwrap_or_default(), ) }); @@ -176,7 +236,7 @@ impl JwtCredentialValidator { }; if validation_errors.is_empty() { - Ok(credential_token) + Ok(()) } else { Err(CompoundCredentialValidationError { validation_errors }) } @@ -274,6 +334,43 @@ impl JwtCredentialValidator { Ok(credential_token) } + fn verify_signature_with_verifier_v2( + signature_verifier: &S, + credential: &Jwt, + trusted_issuers: &[DOC], + options: &JwsVerificationOptions, + ) -> Result, JwtValidationError> + where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + DOC: AsRef, + S: JwsVerifier, + { + // Note the below steps are necessary because `CoreDocument::verify_jws` decodes the JWS and then searches for a + // method with a fragment (or full DID Url) matching `kid` in the given document. We do not want to carry out + // that process for potentially every document in `trusted_issuers`. + + // Start decoding the credential + let decoded: JwsValidationItem<'_> = Self::decode(credential.as_str())?; + let (public_key, method_id) = Self::parse_jwk(&decoded, trusted_issuers, options)?; + + let credential_token = Self::verify_decoded_signature_v2(decoded, public_key, signature_verifier)?; + + // Check that the DID component of the parsed `kid` does indeed correspond to the issuer in the credential before + // returning. + let issuer_id: CoreDID = CoreDID::from_str(credential_token.credential.issuer.url().as_str()).map_err(|err| { + JwtValidationError::SignerUrl { + signer_ctx: SignerContext::Issuer, + source: err.into(), + } + })?; + if &issuer_id != method_id.did() { + return Err(JwtValidationError::IdentifierMismatch { + signer_ctx: SignerContext::Issuer, + }); + }; + Ok(credential_token) + } + /// Decode the credential into a [`JwsValidationItem`]. pub(crate) fn decode(credential_jws: &str) -> Result, JwtValidationError> { let decoder: Decoder = Decoder::new(); @@ -326,6 +423,36 @@ impl JwtCredentialValidator { custom_claims, }) } + + pub(crate) fn verify_decoded_signature_v2( + decoded: JwsValidationItem<'_>, + public_key: &Jwk, + signature_verifier: &S, + ) -> Result, JwtValidationError> + where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + { + // Verify the JWS signature and obtain the decoded token containing the protected header and raw claims + let DecodedJws { protected, claims, .. } = Self::verify_signature_raw(decoded, public_key, signature_verifier)?; + + let credential_claims: CredentialJwtClaims<'_, T> = + CredentialJwtClaims::from_json_slice(&claims).map_err(|err| { + JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(err.into())) + })?; + + let custom_claims = credential_claims.custom.clone(); + + // Construct the credential token containing the credential and the protected header. + let credential = credential_claims + .try_into_credential_v2() + .map_err(JwtValidationError::CredentialStructure)?; + + Ok(DecodedJwtCredentialV2 { + credential, + header: Box::new(protected), + custom_claims, + }) + } } #[cfg(test)] @@ -370,7 +497,7 @@ mod tests { #[test] fn issued_on_or_before() { assert!(JwtCredentialValidatorUtils::check_issued_on_or_before( - &SIMPLE_CREDENTIAL, + &*SIMPLE_CREDENTIAL, SIMPLE_CREDENTIAL .issuance_date .checked_sub(Duration::minutes(1)) @@ -380,7 +507,7 @@ mod tests { // and now with a later timestamp assert!(JwtCredentialValidatorUtils::check_issued_on_or_before( - &SIMPLE_CREDENTIAL, + &*SIMPLE_CREDENTIAL, SIMPLE_CREDENTIAL .issuance_date .checked_add(Duration::minutes(1)) @@ -501,11 +628,11 @@ mod tests { .checked_add(Duration::minutes(1)) .unwrap(); assert!( - JwtCredentialValidatorUtils::check_expires_on_or_after(&SIMPLE_CREDENTIAL, later_than_expiration_date).is_err() + JwtCredentialValidatorUtils::check_expires_on_or_after(&*SIMPLE_CREDENTIAL, later_than_expiration_date).is_err() ); // and now with an earlier date let earlier_date = Timestamp::parse("2019-12-27T11:35:30Z").unwrap(); - assert!(JwtCredentialValidatorUtils::check_expires_on_or_after(&SIMPLE_CREDENTIAL, earlier_date).is_ok()); + assert!(JwtCredentialValidatorUtils::check_expires_on_or_after(&*SIMPLE_CREDENTIAL, earlier_date).is_ok()); } // test with a few timestamps that should be RFC3339 compatible @@ -514,8 +641,8 @@ mod tests { fn property_based_expires_after_with_expiration_date(seconds in 0..1_000_000_000_u32) { let after_expiration_date = SIMPLE_CREDENTIAL.expiration_date.unwrap().checked_add(Duration::seconds(seconds)).unwrap(); let before_expiration_date = SIMPLE_CREDENTIAL.expiration_date.unwrap().checked_sub(Duration::seconds(seconds)).unwrap(); - assert!(JwtCredentialValidatorUtils::check_expires_on_or_after(&SIMPLE_CREDENTIAL, after_expiration_date).is_err()); - assert!(JwtCredentialValidatorUtils::check_expires_on_or_after(&SIMPLE_CREDENTIAL, before_expiration_date).is_ok()); + assert!(JwtCredentialValidatorUtils::check_expires_on_or_after(&*SIMPLE_CREDENTIAL, after_expiration_date).is_err()); + assert!(JwtCredentialValidatorUtils::check_expires_on_or_after(&*SIMPLE_CREDENTIAL, before_expiration_date).is_ok()); } } @@ -535,8 +662,8 @@ mod tests { let earlier_than_issuance_date = SIMPLE_CREDENTIAL.issuance_date.checked_sub(Duration::seconds(seconds)).unwrap(); let later_than_issuance_date = SIMPLE_CREDENTIAL.issuance_date.checked_add(Duration::seconds(seconds)).unwrap(); - assert!(JwtCredentialValidatorUtils::check_issued_on_or_before(&SIMPLE_CREDENTIAL, earlier_than_issuance_date).is_err()); - assert!(JwtCredentialValidatorUtils::check_issued_on_or_before(&SIMPLE_CREDENTIAL, later_than_issuance_date).is_ok()); + assert!(JwtCredentialValidatorUtils::check_issued_on_or_before(&*SIMPLE_CREDENTIAL, earlier_than_issuance_date).is_err()); + assert!(JwtCredentialValidatorUtils::check_issued_on_or_before(&*SIMPLE_CREDENTIAL, later_than_issuance_date).is_ok()); } } } diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_hybrid.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_hybrid.rs index 328a0d3e8c..474ee7d47a 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_hybrid.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_hybrid.rs @@ -80,11 +80,13 @@ impl JwtCredentialValidatorHybrid })?; JwtCredentialValidator::::validate_decoded_credential( - credential_token, + &credential_token.credential, std::slice::from_ref(issuer.as_ref()), options, fail_fast, - ) + )?; + + Ok(credential_token) } /// Decode and verify the PQ/T JWS signature of a [`Credential`] issued as a JWT using the DID Document of a trusted diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs index d454122c15..e82e28a8c7 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs @@ -3,7 +3,6 @@ use std::str::FromStr; use identity_core::common::Object; -use identity_core::common::OneOrMany; use identity_core::common::Timestamp; use identity_core::common::Url; use identity_core::convert::FromJson; @@ -14,6 +13,7 @@ use super::JwtValidationError; use super::SignerContext; use crate::credential::Credential; use crate::credential::CredentialJwtClaims; +use crate::credential::CredentialT; use crate::credential::Jwt; #[cfg(feature = "status-list-2021")] use crate::revocation::status_list_2021::StatusList2021Credential; @@ -31,57 +31,90 @@ impl JwtCredentialValidatorUtils { /// /// # Warning /// This does not validate against the credential's schema nor the structure of the subject claims. - pub fn check_structure(credential: &Credential) -> ValidationUnitResult { - credential - .check_structure() - .map_err(JwtValidationError::CredentialStructure) + pub fn check_structure(credential: &impl CredentialT) -> ValidationUnitResult { + // Ensure the base context is present and in the correct location + match credential.context().get(0) { + Some(context) if context == credential.base_context() => {} + Some(_) | None => { + return Err(JwtValidationError::CredentialStructure( + crate::Error::MissingBaseContext, + )) + } + } + + // The set of types MUST contain the base type + if !credential + .type_() + .iter() + .any(|type_| type_ == Credential::::base_type()) + { + return Err(JwtValidationError::CredentialStructure(crate::Error::MissingBaseType)); + } + + // Credentials MUST have at least one subject + if credential.subject().is_empty() { + return Err(JwtValidationError::CredentialStructure(crate::Error::MissingSubject)); + } + + // Each subject is defined as one or more properties - no empty objects + for subject in credential.subject().iter() { + if subject.id.is_none() && subject.properties.is_empty() { + return Err(JwtValidationError::CredentialStructure(crate::Error::InvalidSubject)); + } + } + + Ok(()) } /// Validate that the [`Credential`] expires on or after the specified [`Timestamp`]. - pub fn check_expires_on_or_after(credential: &Credential, timestamp: Timestamp) -> ValidationUnitResult { - let expiration_date: Option = credential.expiration_date; - (expiration_date.is_none() || expiration_date >= Some(timestamp)) - .then_some(()) - .ok_or(JwtValidationError::ExpirationDate) + pub fn check_expires_on_or_after( + credential: &impl CredentialT, + timestamp: Timestamp, + ) -> ValidationUnitResult { + match credential.valid_until() { + Some(exp) if exp < timestamp => Err(JwtValidationError::ExpirationDate), + _ => Ok(()), + } } /// Validate that the [`Credential`] is issued on or before the specified [`Timestamp`]. - pub fn check_issued_on_or_before(credential: &Credential, timestamp: Timestamp) -> ValidationUnitResult { - (credential.issuance_date <= timestamp) - .then_some(()) - .ok_or(JwtValidationError::IssuanceDate) + pub fn check_issued_on_or_before( + credential: &impl CredentialT, + timestamp: Timestamp, + ) -> ValidationUnitResult { + if credential.valid_from() <= timestamp { + Ok(()) + } else { + Err(JwtValidationError::IssuanceDate) + } } /// Validate that the relationship between the `holder` and the credential subjects is in accordance with /// `relationship`. pub fn check_subject_holder_relationship( - credential: &Credential, + credential: &impl CredentialT, holder: &Url, relationship: SubjectHolderRelationship, ) -> ValidationUnitResult { - let url_matches: bool = match &credential.credential_subject { - OneOrMany::One(ref credential_subject) => credential_subject.id.as_ref() == Some(holder), - OneOrMany::Many(subjects) => { - // need to check the case where the Many variant holds a vector of exactly one subject - if let [credential_subject] = subjects.as_slice() { - credential_subject.id.as_ref() == Some(holder) - } else { - // zero or > 1 subjects is interpreted to mean that the holder is not the subject - false - } + let url_matches = || { + if let [subject] = credential.subject().as_slice() { + subject.id.as_ref() == Some(holder) + } else { + false } }; - Some(relationship) - .filter(|relationship| match relationship { - SubjectHolderRelationship::AlwaysSubject => url_matches, - SubjectHolderRelationship::SubjectOnNonTransferable => { - url_matches || !credential.non_transferable.unwrap_or(false) - } - SubjectHolderRelationship::Any => true, - }) - .map(|_| ()) - .ok_or(JwtValidationError::SubjectHolderRelationship) + let valid = match relationship { + SubjectHolderRelationship::AlwaysSubject => url_matches(), + SubjectHolderRelationship::SubjectOnNonTransferable => url_matches() || !credential.non_transferable(), + SubjectHolderRelationship::Any => true, + }; + + if valid { + Ok(()) + } else { + Err(JwtValidationError::SubjectHolderRelationship) + } } /// Checks whether the status specified in `credentialStatus` has been set by the issuer. @@ -89,7 +122,7 @@ impl JwtCredentialValidatorUtils { /// Only supports `StatusList2021`. #[cfg(feature = "status-list-2021")] pub fn check_status_with_status_list_2021( - credential: &Credential, + credential: &impl CredentialT, status_list_credential: &StatusList2021Credential, status_check: crate::validator::StatusCheck, ) -> ValidationUnitResult { @@ -100,36 +133,36 @@ impl JwtCredentialValidatorUtils { return Ok(()); } - match &credential.credential_status { - None => Ok(()), - Some(status) => { - let status = StatusList2021Entry::try_from(status) - .map_err(|e| JwtValidationError::InvalidStatus(crate::Error::InvalidStatus(e.to_string())))?; - if Some(status.status_list_credential()) == status_list_credential.id.as_ref() - && status.purpose() == status_list_credential.purpose() - { - let entry_status = status_list_credential - .entry(status.index()) - .map_err(|e| JwtValidationError::InvalidStatus(crate::Error::InvalidStatus(e.to_string())))?; - match entry_status { - CredentialStatus::Revoked => Err(JwtValidationError::Revoked), - CredentialStatus::Suspended => Err(JwtValidationError::Suspended), - CredentialStatus::Valid => Ok(()), - } - } else { - Err(JwtValidationError::InvalidStatus(crate::Error::InvalidStatus( - "The given statusListCredential doesn't match the credential's status".to_owned(), - ))) - } + let Some(status) = credential.status() else { + return Ok(()); + }; + + let status = StatusList2021Entry::try_from(status) + .map_err(|e| JwtValidationError::InvalidStatus(crate::Error::InvalidStatus(e.to_string())))?; + if Some(status.status_list_credential()) == status_list_credential.id.as_ref() + && status.purpose() == status_list_credential.purpose() + { + let entry_status = status_list_credential + .entry(status.index()) + .map_err(|e| JwtValidationError::InvalidStatus(crate::Error::InvalidStatus(e.to_string())))?; + match entry_status { + CredentialStatus::Revoked => Err(JwtValidationError::Revoked), + CredentialStatus::Suspended => Err(JwtValidationError::Suspended), + CredentialStatus::Valid => Ok(()), } + } else { + Err(JwtValidationError::InvalidStatus(crate::Error::InvalidStatus( + "The given statusListCredential doesn't match the credential's status".to_owned(), + ))) } } + /// Checks whether the credential status has been revoked. /// /// Only supports `RevocationBitmap2022`. #[cfg(feature = "revocation-bitmap")] pub fn check_status, T>( - credential: &Credential, + credential: &impl CredentialT, trusted_issuers: &[DOC], status_check: crate::validator::StatusCheck, ) -> ValidationUnitResult { @@ -140,32 +173,30 @@ impl JwtCredentialValidatorUtils { return Ok(()); } - match &credential.credential_status { - None => Ok(()), - Some(status) => { - // Check status is supported. - if status.type_ != crate::revocation::RevocationBitmap::TYPE { - if status_check == crate::validator::StatusCheck::SkipUnsupported { - return Ok(()); - } - return Err(JwtValidationError::InvalidStatus(crate::Error::InvalidStatus(format!( - "unsupported type '{}'", - status.type_ - )))); - } - let status: crate::credential::RevocationBitmapStatus = - crate::credential::RevocationBitmapStatus::try_from(status.clone()) - .map_err(JwtValidationError::InvalidStatus)?; - - // Check the credential index against the issuer's DID Document. - let issuer_did: CoreDID = Self::extract_issuer(credential)?; - trusted_issuers - .iter() - .find(|issuer| ::id(issuer.as_ref()) == &issuer_did) - .ok_or(JwtValidationError::DocumentMismatch(SignerContext::Issuer)) - .and_then(|issuer| Self::check_revocation_bitmap_status(issuer, status)) + let Some(status) = credential.status() else { + return Ok(()); + }; + + // Check status is supported. + if status.type_ != crate::revocation::RevocationBitmap::TYPE { + if status_check == crate::validator::StatusCheck::SkipUnsupported { + return Ok(()); } + return Err(JwtValidationError::InvalidStatus(crate::Error::InvalidStatus(format!( + "unsupported type '{}'", + status.type_ + )))); } + let status: crate::credential::RevocationBitmapStatus = + crate::credential::RevocationBitmapStatus::try_from(status.clone()).map_err(JwtValidationError::InvalidStatus)?; + + // Check the credential index against the issuer's DID Document. + let issuer_did: CoreDID = Self::extract_issuer(credential)?; + trusted_issuers + .iter() + .find(|issuer| ::id(issuer.as_ref()) == &issuer_did) + .ok_or(JwtValidationError::DocumentMismatch(SignerContext::Issuer)) + .and_then(|issuer| Self::check_revocation_bitmap_status(issuer, status)) } /// Check the given `status` against the matching [`RevocationBitmap`] service in the @@ -197,12 +228,14 @@ impl JwtCredentialValidatorUtils { /// # Errors /// /// Fails if the issuer field is not a valid DID. - pub fn extract_issuer(credential: &Credential) -> std::result::Result + pub fn extract_issuer( + credential: &impl CredentialT, + ) -> std::result::Result where D: DID, ::Err: std::error::Error + Send + Sync + 'static, { - D::from_str(credential.issuer.url().as_str()).map_err(|err| JwtValidationError::SignerUrl { + D::from_str(credential.issuer().url().as_str()).map_err(|err| JwtValidationError::SignerUrl { signer_ctx: SignerContext::Issuer, source: err.into(), }) diff --git a/identity_credential/src/validator/sd_jwt/validator.rs b/identity_credential/src/validator/sd_jwt/validator.rs index dc8aa6efd1..eeb529dc67 100644 --- a/identity_credential/src/validator/sd_jwt/validator.rs +++ b/identity_credential/src/validator/sd_jwt/validator.rs @@ -76,7 +76,7 @@ impl SdJwtCredentialValidator { fail_fast: FailFast, ) -> Result, CompoundCredentialValidationError> where - T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + T: Clone + serde::Serialize + serde::de::DeserializeOwned, DOC: AsRef, { let issuers = std::slice::from_ref(issuer.as_ref()); @@ -86,7 +86,9 @@ impl SdJwtCredentialValidator { validation_errors: [err].into(), })?; - JwtCredentialValidator::::validate_decoded_credential(credential, issuers, options, fail_fast) + JwtCredentialValidator::::validate_decoded_credential(&credential.credential, issuers, options, fail_fast)?; + + Ok(credential) } /// Decode and verify the JWS signature of a [`Credential`] issued as an SD-JWT using the DID Document of a trusted diff --git a/identity_iota_core/packages/iota_identity/Move.lock b/identity_iota_core/packages/iota_identity/Move.lock index 0729fe1508..1145bff556 100644 --- a/identity_iota_core/packages/iota_identity/Move.lock +++ b/identity_iota_core/packages/iota_identity/Move.lock @@ -42,7 +42,7 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.4.1" +compiler-version = "1.7.0" edition = "2024" flavor = "iota" @@ -61,9 +61,9 @@ latest-published-id = "0xc04befdea27caa7e277f0b738bfcd29fc463cd2a5885ae7f0a9fd3e published-version = "3" [env.localnet] -chain-id = "910e9785" -original-published-id = "0xeb53f2d0bef988a0484ea3c7f9c5a4bc2958b9e67f97afd61a0961ca972fe6e5" -latest-published-id = "0xeb53f2d0bef988a0484ea3c7f9c5a4bc2958b9e67f97afd61a0961ca972fe6e5" +chain-id = "a8b1852e" +original-published-id = "0x8e5fafc34b809058adbe9baced438e2b22630348cdd12606854a81ca929c21af" +latest-published-id = "0x8e5fafc34b809058adbe9baced438e2b22630348cdd12606854a81ca929c21af" published-version = "1" [env.mainnet] diff --git a/identity_storage/src/storage/jwk_document_ext.rs b/identity_storage/src/storage/jwk_document_ext.rs index f9ee100986..9888efd905 100644 --- a/identity_storage/src/storage/jwk_document_ext.rs +++ b/identity_storage/src/storage/jwk_document_ext.rs @@ -16,7 +16,7 @@ use crate::key_storage::KeyType; use async_trait::async_trait; use identity_core::common::Object; -use identity_credential::credential::Credential; +use identity_credential::credential::CredentialT; use identity_credential::credential::Jws; use identity_credential::credential::Jwt; use identity_credential::presentation::JwtPresentationOptions; @@ -96,7 +96,8 @@ pub trait JwkDocumentExt: private::Sealed { I: KeyIdStorage; /// Produces a JWT where the payload is produced from the given `credential` - /// in accordance with [VC Data Model v1.1](https://www.w3.org/TR/vc-data-model/#json-web-token). + /// in accordance with either [VC Data Model v1.1](https://www.w3.org/TR/vc-data-model/#json-web-token) + /// or [VC Data Model v2.0](https://www.w3.org/TR/vc-data-model-2.0/). /// /// Unless the `kid` is explicitly set in the options, the `kid` in the protected header is the `id` /// of the method identified by `fragment` and the JWS signature will be produced by the corresponding @@ -105,7 +106,7 @@ pub trait JwkDocumentExt: private::Sealed { /// The `custom_claims` can be used to set additional claims on the resulting JWT. async fn create_credential_jwt( &self, - credential: &Credential, + credential: &(impl CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, @@ -439,7 +440,7 @@ impl JwkDocumentExt for CoreDocument { async fn create_credential_jwt( &self, - credential: &Credential, + credential: &(impl CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, @@ -591,7 +592,7 @@ mod iota_document { async fn create_credential_jwt( &self, - credential: &Credential, + credential: &(impl CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, From 2209e9c01ac0a34350871a0db5911a3c7a1ca394 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Tue, 28 Oct 2025 14:25:55 +0100 Subject: [PATCH 2/8] VC Data Model 2 doesn't use canonical JWT claims --- .../src/credential/credential_v2.rs | 6 +- .../src/credential/jwt_serialization.rs | 116 ------------------ .../decoded_jwt_credential.rs | 2 - .../jwt_credential_validator.rs | 14 +-- 4 files changed, 4 insertions(+), 134 deletions(-) diff --git a/identity_credential/src/credential/credential_v2.rs b/identity_credential/src/credential/credential_v2.rs index b2ca215d0f..1293dfafba 100644 --- a/identity_credential/src/credential/credential_v2.rs +++ b/identity_credential/src/credential/credential_v2.rs @@ -18,7 +18,6 @@ use serde::Deserializer; use serde::Serialize; use crate::credential::CredentialBuilder; -use crate::credential::CredentialJwtClaims; use crate::credential::CredentialSealed; use crate::credential::CredentialT; use crate::credential::Evidence; @@ -240,9 +239,8 @@ where /// in accordance with [VC Data Model v2.0](https://www.w3.org/TR/vc-data-model-2.0/). /// /// The resulting string can be used as the payload of a JWS when issuing the credential. - pub fn serialize_jwt(&self, custom_claims: Option) -> Result { - let jwt_representation: CredentialJwtClaims<'_, T> = CredentialJwtClaims::new_v2(self, custom_claims)?; - jwt_representation + pub fn serialize_jwt(&self, _custom_claims: Option) -> Result { + self .to_json() .map_err(|err| Error::JwtClaimsSetSerializationError(err.into())) } diff --git a/identity_credential/src/credential/jwt_serialization.rs b/identity_credential/src/credential/jwt_serialization.rs index ddb7fed1f1..eb85f33506 100644 --- a/identity_credential/src/credential/jwt_serialization.rs +++ b/identity_credential/src/credential/jwt_serialization.rs @@ -15,7 +15,6 @@ use identity_core::common::Timestamp; use identity_core::common::Url; use serde::de::DeserializeOwned; -use crate::credential::credential_v2::Credential as CredentialV2; use crate::credential::Credential; use crate::credential::Evidence; use crate::credential::Issuer; @@ -131,57 +130,6 @@ where custom, }) } - - pub(crate) fn new_v2(credential: &'credential CredentialV2, custom: Option) -> Result { - let CredentialV2 { - context, - id, - types, - credential_subject: OneOrMany::One(subject), - issuer, - valid_from, - valid_until, - credential_status, - credential_schema, - refresh_service, - terms_of_use, - evidence, - non_transferable, - properties, - proof, - } = credential - else { - return Err(Error::MoreThanOneSubjectInJwt); - }; - - Ok(Self { - exp: valid_until.map(|value| Timestamp::to_unix(&value)), - iss: Cow::Borrowed(issuer), - issuance_date: IssuanceDateClaims::new(*valid_from), - jti: id.as_ref().map(Cow::Borrowed), - sub: subject.id.as_ref().map(Cow::Borrowed), - vc: InnerCredential { - context: Cow::Borrowed(context), - id: None, - types: Cow::Borrowed(types), - credential_subject: InnerCredentialSubject::new(subject), - issuance_date: None, - expiration_date: None, - valid_from: None, - valid_until: None, - issuer: None, - credential_schema: Cow::Borrowed(credential_schema), - credential_status: credential_status.as_ref().map(Cow::Borrowed), - refresh_service: Cow::Borrowed(refresh_service), - terms_of_use: Cow::Borrowed(terms_of_use), - evidence: Cow::Borrowed(evidence), - non_transferable: *non_transferable, - properties: Cow::Borrowed(properties), - proof: proof.as_ref().map(Cow::Borrowed), - }, - custom, - }) - } } #[cfg(feature = "validator")] @@ -311,70 +259,6 @@ where proof: proof.map(Cow::into_owned), }) } - - /// Converts the JWT representation into a [`CredentialV2`]. - /// - /// # Errors - /// Errors if either timestamp conversion or [`Self::check_consistency`] fails. - pub(crate) fn try_into_credential_v2(self) -> Result> { - self.check_consistency()?; - - let Self { - exp, - iss, - issuance_date, - jti, - sub, - vc, - .. - } = self; - - let InnerCredential { - context, - types, - credential_subject, - credential_status, - credential_schema, - refresh_service, - terms_of_use, - evidence, - non_transferable, - properties, - proof, - .. - } = vc; - - // Make sure inner credential contains the right context - if context.first() != Some(&crate::credential::credential_v2::BASE_CONTEXT) { - return Err(Error::MissingBaseContext); - } - - Ok(CredentialV2 { - context: context.into_owned(), - id: jti.map(Cow::into_owned), - types: types.into_owned(), - credential_subject: { - OneOrMany::One(Subject { - id: sub.map(Cow::into_owned), - properties: credential_subject.properties.into_owned(), - }) - }, - issuer: iss.into_owned(), - valid_from: issuance_date.to_issuance_date()?, - valid_until: exp - .map(Timestamp::from_unix) - .transpose() - .map_err(|_| Error::TimestampConversionError)?, - credential_status: credential_status.map(Cow::into_owned), - credential_schema: credential_schema.into_owned(), - refresh_service: refresh_service.into_owned(), - terms_of_use: terms_of_use.into_owned(), - evidence: evidence.into_owned(), - non_transferable, - properties: properties.into_owned(), - proof: proof.map(Cow::into_owned), - }) - } } /// The [VC Data Model v1.1](https://www.w3.org/TR/vc-data-model/#json-web-token) states that issuanceDate diff --git a/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs b/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs index be1619a99a..1428e3a231 100644 --- a/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs +++ b/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs @@ -30,6 +30,4 @@ pub struct DecodedJwtCredentialV2 { pub credential: CredentialV2, /// The protected header parsed from the JWS. pub header: Box, - /// The custom claims parsed from the JWT. - pub custom_claims: Option, } diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs index afc4f14b30..a8ac93159d 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs @@ -435,22 +435,12 @@ impl JwtCredentialValidator { // Verify the JWS signature and obtain the decoded token containing the protected header and raw claims let DecodedJws { protected, claims, .. } = Self::verify_signature_raw(decoded, public_key, signature_verifier)?; - let credential_claims: CredentialJwtClaims<'_, T> = - CredentialJwtClaims::from_json_slice(&claims).map_err(|err| { - JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(err.into())) - })?; - - let custom_claims = credential_claims.custom.clone(); - - // Construct the credential token containing the credential and the protected header. - let credential = credential_claims - .try_into_credential_v2() - .map_err(JwtValidationError::CredentialStructure)?; + let credential = serde_json::from_slice(&claims) + .map_err(|e| JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(e.into())))?; Ok(DecodedJwtCredentialV2 { credential, header: Box::new(protected), - custom_claims, }) } } From 2b143e29b74632e4f267d832792571e62094eca2 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Mon, 3 Nov 2025 14:47:46 +0100 Subject: [PATCH 3/8] JWT test --- identity_credential/src/credential/credential_v2.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/identity_credential/src/credential/credential_v2.rs b/identity_credential/src/credential/credential_v2.rs index 1293dfafba..4201c3d330 100644 --- a/identity_credential/src/credential/credential_v2.rs +++ b/identity_credential/src/credential/credential_v2.rs @@ -248,6 +248,8 @@ where #[cfg(test)] mod tests { + use identity_verification::jws::Decoder; + use super::*; #[test] @@ -282,4 +284,14 @@ mod tests { let json_credential = include_str!("../../tests/fixtures/credential-1.json"); let _error = serde_json::from_str::(json_credential).unwrap_err(); } + + #[test] + fn parsed_from_jwt_payload() { + let jwt = "eyJraWQiOiJFeEhrQk1XOWZtYmt2VjI2Nm1ScHVQMnNVWV9OX0VXSU4xbGFwVXpPOHJvIiwiYWxnIjoiRVMyNTYifQ.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjIiXSwiaWQiOiJodHRwOi8vdW5pdmVyc2l0eS5leGFtcGxlL2NyZWRlbnRpYWxzLzM3MzIiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiRXhhbXBsZURlZ3JlZUNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiaHR0cHM6Ly91bml2ZXJzaXR5LmV4YW1wbGUvaXNzdWVycy81NjUwNDkiLCJ2YWxpZEZyb20iOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmV4YW1wbGU6ZWJmZWIxZjcxMmViYzZmMWMyNzZlMTJlYzIxIiwiZGVncmVlIjp7InR5cGUiOiJFeGFtcGxlQmFjaGVsb3JEZWdyZWUiLCJuYW1lIjoiQmFjaGVsb3Igb2YgU2NpZW5jZSBhbmQgQXJ0cyJ9fX0.YEsG9at9Hnt_j-UykCrnl494fcYMTjzpgvlK0KzzjvfmZmSg-sNVJqMZWizYhWv_eRUvAoZohvSJWeagwj_Ajw"; + let decoded_jwt = Decoder::new() + .decode_compact_serialization(jwt.as_bytes(), None) + .expect("valid JWT"); + + let _credential: Credential = serde_json::from_slice(decoded_jwt.claims()).expect("valid JWT payload"); + } } From 5a387de2c930f0c066c831940fca7a1756d2ffeb Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Mon, 3 Nov 2025 16:26:10 +0100 Subject: [PATCH 4/8] cargo clippy --- identity_credential/src/validator/options.rs | 18 ++++-------------- identity_jose/src/jws/format.rs | 9 ++------- .../src/verification_method/method_scope.rs | 9 ++------- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/identity_credential/src/validator/options.rs b/identity_credential/src/validator/options.rs index 2661241ed1..80d3dcdd3a 100644 --- a/identity_credential/src/validator/options.rs +++ b/identity_credential/src/validator/options.rs @@ -6,7 +6,7 @@ use serde::Serialize; /// Controls validation behaviour when checking whether or not a credential has been revoked by its /// [`credentialStatus`](https://www.w3.org/TR/vc-data-model/#status). -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Default)] #[repr(u8)] pub enum StatusCheck { /// Validate the status if supported, reject any unsupported @@ -15,6 +15,7 @@ pub enum StatusCheck { /// Only `RevocationBitmap2022` is currently supported. /// /// This is the default. + #[default] Strict = 0, /// Validate the status if supported, skip any unsupported /// [`credentialStatus`](https://www.w3.org/TR/vc-data-model/#status) types. @@ -23,22 +24,17 @@ pub enum StatusCheck { SkipAll = 2, } -impl Default for StatusCheck { - fn default() -> Self { - Self::Strict - } -} - /// Declares how credential subjects must relate to the presentation holder during validation. /// /// See also the [Subject-Holder Relationship](https://www.w3.org/TR/vc-data-model/#subject-holder-relationships) section of the specification. // Need to use serde_repr to make this work with duck typed interfaces in the Wasm bindings. -#[derive(Debug, Clone, Copy, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)] +#[derive(Debug, Clone, Copy, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Default)] #[repr(u8)] pub enum SubjectHolderRelationship { /// The holder must always match the subject on all credentials, regardless of their [`nonTransferable`](https://www.w3.org/TR/vc-data-model/#nontransferable-property) property. /// This is the variant returned by [Self::default](Self::default()) and the default used in /// [`crate::validator::JwtPresentationValidationOptions`]. + #[default] AlwaysSubject = 0, /// The holder must match the subject only for credentials where the [`nonTransferable`](https://www.w3.org/TR/vc-data-model/#nontransferable-property) property is `true`. SubjectOnNonTransferable = 1, @@ -46,12 +42,6 @@ pub enum SubjectHolderRelationship { Any = 2, } -impl Default for SubjectHolderRelationship { - fn default() -> Self { - Self::AlwaysSubject - } -} - /// Declares when validation should return if an error occurs. #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub enum FailFast { diff --git a/identity_jose/src/jws/format.rs b/identity_jose/src/jws/format.rs index 35c8d939c4..49e9eb53cb 100644 --- a/identity_jose/src/jws/format.rs +++ b/identity_jose/src/jws/format.rs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 /// The serialization format used for the JWS. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum JwsFormat { /// JWS Compact Serialization (). + #[default] Compact, /// General JWS JSON Serialization (). General, @@ -13,9 +14,3 @@ pub enum JwsFormat { /// Should be used for single signature or MAC use cases. Flatten, } - -impl Default for JwsFormat { - fn default() -> Self { - Self::Compact - } -} diff --git a/identity_verification/src/verification_method/method_scope.rs b/identity_verification/src/verification_method/method_scope.rs index b7551b8b16..b5e50a5f3d 100644 --- a/identity_verification/src/verification_method/method_scope.rs +++ b/identity_verification/src/verification_method/method_scope.rs @@ -15,9 +15,10 @@ use crate::verification_method::MethodRelationship; /// /// Can either refer to a generic method embedded in the verification method field, /// or to a verification relationship. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Default)] pub enum MethodScope { /// The scope of generic verification methods. + #[default] VerificationMethod, /// The scope of a specific [`MethodRelationship`]. VerificationRelationship(MethodRelationship), @@ -58,12 +59,6 @@ impl MethodScope { } } -impl Default for MethodScope { - fn default() -> Self { - Self::VerificationMethod - } -} - impl FromStr for MethodScope { type Err = Error; From 34c15222f2f78575cb856f1e3159ebc8eba07f99 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Thu, 6 Nov 2025 13:45:07 +0100 Subject: [PATCH 5/8] wasm bindings for CredentialV2 --- .../examples/src/0_basic/8_create_vc_v2.ts | 91 ++++ .../src/credential/credential_builder.rs | 1 + .../src/credential/credential_v2.rs | 401 ++++++++++++++++++ .../decoded_jwt_credential.rs | 42 +- .../jwt_credential_validator.rs | 116 ++++- .../wasm/identity_wasm/src/credential/mod.rs | 29 ++ .../src/did/wasm_core_document.rs | 9 +- .../identity_wasm/src/iota/iota_document.rs | 8 +- .../wasm/identity_wasm/src/sd_jwt/encoder.rs | 2 +- .../domain_linkage_credential_builder.rs | 44 ++ .../jwt_credential_validator.rs | 31 +- .../jwt_credential_validator_utils.rs | 14 +- .../src/storage/jwk_document_ext.rs | 6 +- 13 files changed, 757 insertions(+), 37 deletions(-) create mode 100644 bindings/wasm/identity_wasm/examples/src/0_basic/8_create_vc_v2.ts create mode 100644 bindings/wasm/identity_wasm/src/credential/credential_v2.rs diff --git a/bindings/wasm/identity_wasm/examples/src/0_basic/8_create_vc_v2.ts b/bindings/wasm/identity_wasm/examples/src/0_basic/8_create_vc_v2.ts new file mode 100644 index 0000000000..e1e870323d --- /dev/null +++ b/bindings/wasm/identity_wasm/examples/src/0_basic/8_create_vc_v2.ts @@ -0,0 +1,91 @@ +// Copyright 2020-2023 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + CredentialV2, + EdDSAJwsVerifier, + FailFast, + JwsSignatureOptions, + JwtCredentialValidationOptions, + JwtCredentialValidator, +} from "@iota/identity-wasm/node"; +import { IotaClient } from "@iota/iota-sdk/client"; +import { createDocumentForNetwork, getFundedClient, getMemstorage, NETWORK_URL } from "../util"; + +/** + * This example shows how to create a Verifiable Credential and validate it. + * In this example, Alice takes the role of the subject, while we also have an issuer. + * The issuer signs a UniversityDegreeCredential type verifiable credential with Alice's name and DID. + * This Verifiable Credential can be verified by anyone, allowing Alice to take control of it and share it with whomever they please. + */ +export async function createVC() { + // create new client to connect to IOTA network + const iotaClient = new IotaClient({ url: NETWORK_URL }); + const network = await iotaClient.getChainIdentifier(); + + // Create an identity for the issuer with one verification method `key-1`, and publish DID document for it. + const issuerStorage = getMemstorage(); + const issuerClient = await getFundedClient(issuerStorage); + const [unpublishedIssuerDocument, issuerFragment] = await createDocumentForNetwork(issuerStorage, network); + const { output: issuerIdentity } = await issuerClient + .createIdentity(unpublishedIssuerDocument) + .finish() + .buildAndExecute(issuerClient); + const issuerDocument = issuerIdentity.didDocument(); + + // Create an identity for the holder, and publish DID document for it, in this case also the subject. + const aliceStorage = getMemstorage(); + const aliceClient = await getFundedClient(aliceStorage); + const [unpublishedAliceDocument] = await createDocumentForNetwork(aliceStorage, network); + const { output: aliceIdentity } = await aliceClient + .createIdentity(unpublishedAliceDocument) + .finish() + .buildAndExecute(aliceClient); + const aliceDocument = aliceIdentity.didDocument(); + + // Create a credential subject indicating the degree earned by Alice, linked to their DID. + const subject = { + id: aliceDocument.id(), + name: "Alice", + degreeName: "Bachelor of Science and Arts", + degreeType: "BachelorDegree", + GPA: "4.0", + }; + + // Create an unsigned `UniversityDegree` credential for Alice + const unsignedVc = new CredentialV2({ + id: "https://example.edu/credentials/3732", + type: "UniversityDegreeCredential", + issuer: issuerDocument.id(), + credentialSubject: subject, + }); + + // Create signed JWT credential. + const credentialJwt = await issuerDocument.createCredentialJwt( + issuerStorage, + issuerFragment, + unsignedVc, + new JwsSignatureOptions(), + ); + console.log(`Credential JWT > ${credentialJwt.toString()}`); + + // Before sending this credential to the holder the issuer wants to validate that some properties + // of the credential satisfy their expectations. + + // Validate the credential's signature, the credential's semantic structure, + // check that the issuance date is not in the future and that the expiration date is not in the past. + // Note that the validation returns an object containing the decoded credential. + const decoded_credential = new JwtCredentialValidator(new EdDSAJwsVerifier()).validateV2( + credentialJwt, + issuerDocument, + new JwtCredentialValidationOptions(), + FailFast.FirstError, + ); + + // Since `validate` did not throw any errors we know that the credential was successfully validated. + console.log(`VC successfully validated`); + + // The issuer is now sure that the credential they are about to issue satisfies their expectations. + // Note that the credential is NOT published to the IOTA Tangle. It is sent and stored off-chain. + console.log(`Issued credential: ${JSON.stringify(decoded_credential.intoCredential(), null, 2)}`); +} diff --git a/bindings/wasm/identity_wasm/src/credential/credential_builder.rs b/bindings/wasm/identity_wasm/src/credential/credential_builder.rs index f96841be13..1c4e2640f1 100644 --- a/bindings/wasm/identity_wasm/src/credential/credential_builder.rs +++ b/bindings/wasm/identity_wasm/src/credential/credential_builder.rs @@ -108,6 +108,7 @@ impl TryFrom for CredentialBuilder { #[wasm_bindgen] extern "C" { + #[derive(Clone)] #[wasm_bindgen(typescript_type = "ICredential")] pub type ICredential; } diff --git a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs new file mode 100644 index 0000000000..745bf40f8c --- /dev/null +++ b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs @@ -0,0 +1,401 @@ +// Copyright 2020-2022 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_iota::core::Context; +use identity_iota::core::Object; +use identity_iota::core::OneOrMany; +use identity_iota::core::Timestamp; +use identity_iota::core::Url; +use identity_iota::credential::credential_v2::Credential as CredentialV2; +use identity_iota::credential::CredentialBuilder; +use identity_iota::credential::DomainLinkageCredentialBuilder; +use identity_iota::credential::Evidence; +use identity_iota::credential::Issuer; +use identity_iota::credential::Policy; +use identity_iota::credential::Proof; +use identity_iota::credential::RefreshService; +use identity_iota::credential::Schema; +use identity_iota::credential::Status; +use identity_iota::credential::Subject; +use proc_typescript::typescript; +use serde_json::Value; +use std::collections::BTreeMap; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +use crate::common::ArrayString; +use crate::common::MapStringAny; +use crate::common::RecordStringAny; +use crate::common::WasmTimestamp; +use crate::credential::domain_linkage_credential_builder::IDomainLinkageCredential; +use crate::credential::ArrayContext; +use crate::credential::ArrayEvidence; +use crate::credential::ArrayPolicy; +use crate::credential::ArrayRefreshService; +use crate::credential::ArraySchema; +use crate::credential::ArrayStatus; +use crate::credential::ArraySubject; +use crate::credential::UrlOrIssuer; +use crate::credential::WasmProof; +use crate::error::Result; +use crate::error::WasmResult; + +/// Represents a set of claims describing an entity. +#[wasm_bindgen(js_name = CredentialV2, inspectable)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WasmCredentialV2(pub(crate) CredentialV2); + +#[wasm_bindgen(js_class = CredentialV2)] +impl WasmCredentialV2 { + /// Returns the base JSON-LD context. + #[wasm_bindgen(js_name = "BaseContext")] + pub fn base_context() -> Result { + match CredentialV2::::base_context() { + Context::Url(url) => Ok(url.to_string()), + Context::Obj(_) => Err(JsError::new("Credential.BaseContext should be a single URL").into()), + } + } + + /// Returns the base type. + #[wasm_bindgen(js_name = "BaseType")] + pub fn base_type() -> String { + CredentialV2::::base_type().to_owned() + } + + /// Constructs a new {@link Credential}. + #[wasm_bindgen(constructor)] + pub fn new(values: ICredentialV2) -> Result { + let builder: CredentialBuilder = CredentialBuilder::try_from(values)?; + builder.build_v2().map(Self).wasm_result() + } + + #[wasm_bindgen(js_name = "createDomainLinkageCredential")] + pub fn create_domain_linkage_credential(values: IDomainLinkageCredential) -> Result { + let builder: DomainLinkageCredentialBuilder = DomainLinkageCredentialBuilder::try_from(values)?; + builder.build_v2().map(Self).wasm_result() + } + + /// Returns a copy of the JSON-LD context(s) applicable to the {@link Credential}. + #[wasm_bindgen] + pub fn context(&self) -> Result { + self + .0 + .context + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns a copy of the unique `URI` identifying the {@link Credential} . + #[wasm_bindgen] + pub fn id(&self) -> Option { + self.0.id.as_ref().map(|url| url.to_string()) + } + + /// Returns a copy of the URIs defining the type of the {@link Credential}. + #[wasm_bindgen(js_name = "type")] + pub fn types(&self) -> ArrayString { + self + .0 + .types + .iter() + .map(|s| s.as_str()) + .map(JsValue::from_str) + .collect::() + .unchecked_into::() + } + + /// Returns a copy of the {@link Credential} subject(s). + #[wasm_bindgen(js_name = credentialSubject)] + pub fn credential_subject(&self) -> Result { + self + .0 + .credential_subject + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns a copy of the issuer of the {@link Credential}. + #[wasm_bindgen] + pub fn issuer(&self) -> Result { + JsValue::from_serde(&self.0.issuer) + .map(|value| value.unchecked_into::()) + .wasm_result() + } + + /// Returns a copy of the timestamp of when the {@link Credential} becomes valid. + #[wasm_bindgen(js_name = "validFrom")] + pub fn valid_from(&self) -> WasmTimestamp { + WasmTimestamp::from(self.0.valid_from) + } + + /// Returns a copy of the timestamp of when the {@link Credential} should no longer be considered valid. + #[wasm_bindgen(js_name = "validUntil")] + pub fn valid_until(&self) -> Option { + self.0.valid_until.map(WasmTimestamp::from) + } + + /// Returns a copy of the information used to determine the current status of the {@link Credential}. + #[wasm_bindgen(js_name = "credentialStatus")] + pub fn credential_status(&self) -> Result { + self + .0 + .credential_status + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns a copy of the information used to assist in the enforcement of a specific {@link Credential} structure. + #[wasm_bindgen(js_name = "credentialSchema")] + pub fn credential_schema(&self) -> Result { + self + .0 + .credential_schema + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns a copy of the service(s) used to refresh an expired {@link Credential}. + #[wasm_bindgen(js_name = "refreshService")] + pub fn refresh_service(&self) -> Result { + self + .0 + .refresh_service + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns a copy of the terms-of-use specified by the {@link Credential} issuer. + #[wasm_bindgen(js_name = "termsOfUse")] + pub fn terms_of_use(&self) -> Result { + self + .0 + .terms_of_use + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns a copy of the human-readable evidence used to support the claims within the {@link Credential}. + #[wasm_bindgen] + pub fn evidence(&self) -> Result { + self + .0 + .evidence + .iter() + .map(JsValue::from_serde) + .collect::>() + .wasm_result() + .map(|value| value.unchecked_into::()) + } + + /// Returns whether or not the {@link Credential} must only be contained within a {@link Presentation} + /// with a proof issued from the {@link Credential} subject. + #[wasm_bindgen(js_name = "nonTransferable")] + pub fn non_transferable(&self) -> Option { + self.0.non_transferable + } + + /// Optional cryptographic proof, unrelated to JWT. + #[wasm_bindgen] + pub fn proof(&self) -> Option { + self.0.proof.clone().map(WasmProof) + } + + /// Returns a copy of the miscellaneous properties on the {@link Credential}. + #[wasm_bindgen] + pub fn properties(&self) -> Result { + MapStringAny::try_from(&self.0.properties) + } + + /// Serializes the `Credential` as a JWT claims set + /// in accordance with [VC Data Model v2.0](https://www.w3.org/TR/vc-data-model-2.0/). + /// + /// The resulting object can be used as the payload of a JWS when issuing the credential. + #[wasm_bindgen(js_name = "toJwtClaims")] + pub fn to_jwt_claims(&self, custom_claims: Option) -> Result { + let serialized: String = if let Some(object) = custom_claims { + let object: BTreeMap = object.into_serde().wasm_result()?; + self.0.serialize_jwt(Some(object)).wasm_result()? + } else { + self.0.serialize_jwt(None).wasm_result()? + }; + let serialized: BTreeMap = serde_json::from_str(&serialized).wasm_result()?; + Ok( + JsValue::from_serde(&serialized) + .wasm_result()? + .unchecked_into::(), + ) + } +} + +impl_wasm_json!(WasmCredentialV2, CredentialV2); +impl_wasm_clone!(WasmCredentialV2, CredentialV2); + +impl From for WasmCredentialV2 { + fn from(credential: CredentialV2) -> WasmCredentialV2 { + Self(credential) + } +} + +#[wasm_bindgen] +extern "C" { + #[derive(Clone)] + #[wasm_bindgen(typescript_type = "ICredentialV2")] + pub type ICredentialV2; +} + +/// Fields for constructing a new {@link Credential}. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[typescript(name = "ICredentialV2", readonly, optional)] +pub(crate) struct ICredentialHelperV2 { + /// The JSON-LD context(s) applicable to the {@link Credential}. + #[typescript(type = "string | Record | Array>")] + context: Option>, + /// A unique URI that may be used to identify the {@link Credential}. + #[typescript(type = "string")] + id: Option, + /// One or more URIs defining the type of the {@link Credential}. Contains the base context by default. + #[typescript(name = "type", type = "string | Array")] + r#type: Option>, + /// One or more objects representing the {@link Credential} subject(s). + #[typescript(optional = false, name = "credentialSubject", type = "Subject | Array")] + credential_subject: Option>, + /// A reference to the issuer of the {@link Credential}. + #[typescript(optional = false, type = "string | CoreDID | IotaDID | Issuer")] + issuer: Option, + /// A timestamp of when the {@link Credential} becomes valid. Defaults to the current datetime. + #[typescript(name = "validFrom", type = "Timestamp")] + valid_from: Option, + /// A timestamp of when the {@link Credential} should no longer be considered valid. + #[typescript(name = "validUntil", type = "Timestamp")] + valid_until: Option, + /// Information used to determine the current status of the {@link Credential}. + #[typescript(name = "credentialStatus", type = "Status")] + credential_status: Option, + /// Information used to assist in the enforcement of a specific {@link Credential} structure. + #[typescript(name = "credentialSchema", type = "Schema | Array")] + credential_schema: Option>, + /// Service(s) used to refresh an expired {@link Credential}. + #[typescript(name = "refreshService", type = "RefreshService | Array")] + refresh_service: Option>, + /// Terms-of-use specified by the {@link Credential} issuer. + #[typescript(name = "termsOfUse", type = "Policy | Array")] + terms_of_use: Option>, + /// Human-readable evidence used to support the claims within the {@link Credential}. + #[typescript(type = "Evidence | Array")] + evidence: Option>, + /// Indicates that the {@link Credential} must only be contained within a {@link Presentation} with a proof issued + /// from the {@link Credential} subject. + #[typescript(name = "nonTransferable", type = "boolean")] + non_transferable: Option, + // The `proof` property of the {@link Credential}. + #[typescript(type = "Proof")] + proof: Option, + /// Miscellaneous properties. + #[serde(flatten)] + #[typescript(optional = false, name = "[properties: string]", type = "unknown")] + properties: Object, +} + +impl TryFrom for CredentialBuilder { + type Error = JsValue; + + fn try_from(values: ICredentialV2) -> std::result::Result { + let ICredentialHelperV2 { + context, + id, + r#type, + credential_subject, + issuer, + valid_from, + valid_until, + credential_status, + credential_schema, + refresh_service, + terms_of_use, + evidence, + non_transferable, + proof, + properties, + } = values.into_serde::().wasm_result()?; + + let mut builder: CredentialBuilder = CredentialBuilder::new(properties); + + if let Some(context) = context { + for value in context.into_vec() { + builder = builder.context(value); + } + } + if let Some(id) = id { + builder = builder.id(Url::parse(id).wasm_result()?); + } + if let Some(types) = r#type { + for value in types.iter() { + builder = builder.type_(value); + } + } + if let Some(credential_subject) = credential_subject { + for subject in credential_subject.into_vec() { + builder = builder.subject(subject); + } + } + if let Some(issuer) = issuer { + builder = builder.issuer(issuer); + } + if let Some(valid_from) = valid_from { + builder = builder.valid_from(valid_from); + } + if let Some(valid_until) = valid_until { + builder = builder.expiration_date(valid_until); + } + if let Some(credential_status) = credential_status { + builder = builder.status(credential_status); + } + if let Some(credential_schema) = credential_schema { + for schema in credential_schema.into_vec() { + builder = builder.schema(schema); + } + } + if let Some(refresh_service) = refresh_service { + for service in refresh_service.into_vec() { + builder = builder.refresh_service(service); + } + } + if let Some(terms_of_use) = terms_of_use { + for policy in terms_of_use.into_vec() { + builder = builder.terms_of_use(policy); + } + } + if let Some(evidence) = evidence { + for value in evidence.into_vec() { + builder = builder.evidence(value); + } + } + if let Some(non_transferable) = non_transferable { + builder = builder.non_transferable(non_transferable); + } + if let Some(proof) = proof { + builder = builder.proof(proof); + } + + Ok(builder) + } +} diff --git a/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs b/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs index c2f9c82964..14efa4b77d 100644 --- a/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs +++ b/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs @@ -1,11 +1,11 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use identity_iota::credential::DecodedJwtCredential; +use identity_iota::credential::{DecodedJwtCredential, DecodedJwtCredentialV2}; use wasm_bindgen::prelude::*; use crate::common::RecordStringAny; -use crate::credential::WasmCredential; +use crate::credential::{WasmCredential, WasmCredentialV2}; use crate::jose::WasmJwsHeader; /// A cryptographically verified and decoded Credential. @@ -57,3 +57,41 @@ impl From for WasmDecodedJwtCredential { Self(credential) } } + +/// A cryptographically verified and decoded {@link CredentialV2}. +/// +/// Note that having an instance of this type only means the JWS it was constructed from was verified. +/// It does not imply anything about a potentially present proof property on the credential itself. +#[wasm_bindgen(js_name = DecodedJwtCredentialV2)] +pub struct WasmDecodedJwtCredentialV2(pub(crate) DecodedJwtCredentialV2); + +#[wasm_bindgen(js_class = DecodedJwtCredentialV2)] +impl WasmDecodedJwtCredentialV2 { + /// Returns a copy of the credential parsed to the [Verifiable Credentials Data model](https://www.w3.org/TR/vc-data-model/). + #[wasm_bindgen] + pub fn credential(&self) -> WasmCredentialV2 { + WasmCredentialV2(self.0.credential.clone()) + } + + /// Returns a copy of the protected header parsed from the decoded JWS. + #[wasm_bindgen(js_name = protectedHeader)] + pub fn protected_header(&self) -> WasmJwsHeader { + WasmJwsHeader(self.0.header.as_ref().clone()) + } + + /// Consumes the object and returns the decoded credential. + /// + /// ### Warning + /// + /// This destroys the {@link DecodedJwtCredential} object. + #[wasm_bindgen(js_name = intoCredential)] + pub fn into_credential(self) -> WasmCredentialV2 { + WasmCredentialV2(self.0.credential) + } +} + +impl From for WasmDecodedJwtCredentialV2 { + fn from(credential: DecodedJwtCredentialV2) -> Self { + Self(credential) + } +} diff --git a/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/jwt_credential_validator.rs b/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/jwt_credential_validator.rs index e15c25a509..fb4d9817b3 100644 --- a/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/jwt_credential_validator.rs +++ b/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/jwt_credential_validator.rs @@ -14,8 +14,9 @@ use crate::common::ImportedDocumentReadGuard; use crate::common::WasmTimestamp; use crate::credential::options::WasmStatusCheck; use crate::credential::revocation::status_list_2021::WasmStatusList2021Credential; -use crate::credential::WasmCredential; +use crate::credential::CredentialAny; use crate::credential::WasmDecodedJwtCredential; +use crate::credential::WasmDecodedJwtCredentialV2; use crate::credential::WasmFailFast; use crate::credential::WasmJwt; use crate::credential::WasmSubjectHolderRelationship; @@ -88,6 +89,48 @@ impl WasmJwtCredentialValidator { .map(WasmDecodedJwtCredential) } + /// Decodes and validates a {@link CredentialV2} issued as a JWS. A {@link DecodedJwtCredentialV2} is returned upon + /// success. + /// + /// The following properties are validated according to `options`: + /// - the issuer's signature on the JWS, + /// - the expiration date, + /// - the issuance date, + /// - the semantic structure. + /// + /// # Warning + /// The lack of an error returned from this method is in of itself not enough to conclude that the credential can be + /// trusted. This section contains more information on additional checks that should be carried out before and after + /// calling this method. + /// + /// ## The state of the issuer's DID Document + /// The caller must ensure that `issuer` represents an up-to-date DID Document. + /// + /// ## Properties that are not validated + /// There are many properties defined in [The Verifiable Credentials Data Model 2.0](https://www.w3.org/TR/vc-data-model-2.0/) that are **not** validated, such as: + /// `proof`, `credentialStatus`, `type`, `credentialSchema`, `refreshService` **and more**. + /// These should be manually checked after validation, according to your requirements. + /// + /// # Errors + /// An error is returned whenever a validated condition is not satisfied. + #[wasm_bindgen(js_name = validateV2)] + pub fn validate_v2( + &self, + credential_jwt: &WasmJwt, + issuer: &IToCoreDocument, + options: &WasmJwtCredentialValidationOptions, + fail_fast: WasmFailFast, + ) -> Result { + let issuer_lock = ImportedDocumentLock::from(issuer); + let issuer_guard = issuer_lock.try_read()?; + + self + .0 + .validate_v2(&credential_jwt.0, &issuer_guard, &options.0, fail_fast.into()) + .wasm_result() + .map(WasmDecodedJwtCredentialV2) + } + /// Decode and verify the JWS signature of a {@link Credential} issued as a JWT using the DID Document of a trusted /// issuer. /// @@ -126,29 +169,73 @@ impl WasmJwtCredentialValidator { .map(WasmDecodedJwtCredential) } + /// Decode and verify the JWS signature of a {@link CredentialV2} issued as a JWT using the DID Document of a trusted + /// issuer. + /// + /// A {@link DecodedJwtCredentialV2} is returned upon success. + /// + /// # Warning + /// The caller must ensure that the DID Documents of the trusted issuers are up-to-date. + /// + /// ## Proofs + /// Only the JWS signature is verified. If the {@link CredentialV2} contains a `proof` property this will not be + /// verified by this method. + /// + /// # Errors + /// This method immediately returns an error if + /// the credential issuer' url cannot be parsed to a DID belonging to one of the trusted issuers. Otherwise an attempt + /// to verify the credential's signature will be made and an error is returned upon failure. + #[wasm_bindgen(js_name = verifySignatureV2)] + #[allow(non_snake_case)] + pub fn verify_signature_v2( + &self, + credential: &WasmJwt, + trustedIssuers: &ArrayIToCoreDocument, + options: &WasmJwsVerificationOptions, + ) -> Result { + let issuer_locks: Vec = trustedIssuers.into(); + let trusted_issuers: Vec> = issuer_locks + .iter() + .map(ImportedDocumentLock::try_read) + .collect::>>>( + )?; + + self + .0 + .verify_signature_v2(&credential.0, &trusted_issuers, &options.0) + .wasm_result() + .map(WasmDecodedJwtCredentialV2) + } + /// Validate that the credential expires on or after the specified timestamp. #[wasm_bindgen(js_name = checkExpiresOnOrAfter)] - pub fn check_expires_on_or_after(credential: &WasmCredential, timestamp: &WasmTimestamp) -> Result<()> { - JwtCredentialValidatorUtils::check_expires_on_or_after(&credential.0, timestamp.0).wasm_result() + pub fn check_expires_on_or_after(credential: &CredentialAny, timestamp: &WasmTimestamp) -> Result<()> { + JwtCredentialValidatorUtils::check_expires_on_or_after(&*credential.try_to_dyn_credential()?, timestamp.0) + .wasm_result() } /// Validate that the credential is issued on or before the specified timestamp. #[wasm_bindgen(js_name = checkIssuedOnOrBefore)] - pub fn check_issued_on_or_before(credential: &WasmCredential, timestamp: &WasmTimestamp) -> Result<()> { - JwtCredentialValidatorUtils::check_issued_on_or_before(&credential.0, timestamp.0).wasm_result() + pub fn check_issued_on_or_before(credential: &CredentialAny, timestamp: &WasmTimestamp) -> Result<()> { + JwtCredentialValidatorUtils::check_issued_on_or_before(&*credential.try_to_dyn_credential()?, timestamp.0) + .wasm_result() } /// Validate that the relationship between the `holder` and the credential subjects is in accordance with /// `relationship`. The `holder` parameter is expected to be the URL of the holder. #[wasm_bindgen(js_name = checkSubjectHolderRelationship)] pub fn check_subject_holder_relationship( - credential: &WasmCredential, + credential: &CredentialAny, holder: &str, relationship: WasmSubjectHolderRelationship, ) -> Result<()> { let holder: Url = Url::parse(holder).wasm_result()?; - JwtCredentialValidatorUtils::check_subject_holder_relationship(&credential.0, &holder, relationship.into()) - .wasm_result() + JwtCredentialValidatorUtils::check_subject_holder_relationship( + &*credential.try_to_dyn_credential()?, + &holder, + relationship.into(), + ) + .wasm_result() } /// Checks whether the credential status has been revoked. @@ -157,7 +244,7 @@ impl WasmJwtCredentialValidator { #[wasm_bindgen(js_name = checkStatus)] #[allow(non_snake_case)] pub fn check_status( - credential: &WasmCredential, + credential: &CredentialAny, trustedIssuers: &ArrayIToCoreDocument, statusCheck: WasmStatusCheck, ) -> Result<()> { @@ -168,18 +255,19 @@ impl WasmJwtCredentialValidator { .collect::>>>( )?; let status_check: StatusCheck = statusCheck.into(); - JwtCredentialValidatorUtils::check_status(&credential.0, &trusted_issuers, status_check).wasm_result() + JwtCredentialValidatorUtils::check_status(&*credential.try_to_dyn_credential()?, &trusted_issuers, status_check) + .wasm_result() } /// Checks whether the credential status has been revoked using `StatusList2021`. #[wasm_bindgen(js_name = checkStatusWithStatusList2021)] pub fn check_status_with_status_list_2021( - credential: &WasmCredential, + credential: &CredentialAny, status_list: &WasmStatusList2021Credential, status_check: WasmStatusCheck, ) -> Result<()> { JwtCredentialValidatorUtils::check_status_with_status_list_2021( - &credential.0, + &*credential.try_to_dyn_credential()?, &status_list.inner, status_check.into(), ) @@ -192,8 +280,8 @@ impl WasmJwtCredentialValidator { /// /// Fails if the issuer field is not a valid DID. #[wasm_bindgen(js_name = extractIssuer)] - pub fn extract_issuer(credential: &WasmCredential) -> Result { - JwtCredentialValidatorUtils::extract_issuer::(&credential.0) + pub fn extract_issuer(credential: &CredentialAny) -> Result { + JwtCredentialValidatorUtils::extract_issuer::(&*credential.try_to_dyn_credential()?) .map(WasmCoreDID::from) .wasm_result() } diff --git a/bindings/wasm/identity_wasm/src/credential/mod.rs b/bindings/wasm/identity_wasm/src/credential/mod.rs index 408f302f11..45f9e6413c 100644 --- a/bindings/wasm/identity_wasm/src/credential/mod.rs +++ b/bindings/wasm/identity_wasm/src/credential/mod.rs @@ -3,8 +3,16 @@ #![allow(clippy::module_inception)] +use identity_iota::core::Object; +use identity_iota::credential::credential_v2::Credential as CredentialV2; +use identity_iota::credential::Credential; +use identity_iota::credential::CredentialT; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; + pub use self::credential::WasmCredential; pub use self::credential_builder::*; +pub use self::credential_v2::*; pub use self::domain_linkage_configuration::WasmDomainLinkageConfiguration; pub use self::jpt::*; pub use self::jpt_credential_validator::*; @@ -23,6 +31,7 @@ pub use self::types::*; mod credential; mod credential_builder; +mod credential_v2; mod domain_linkage_configuration; mod domain_linkage_credential_builder; mod domain_linkage_validator; @@ -40,3 +49,23 @@ mod presentation; mod proof; mod revocation; mod types; + +#[wasm_bindgen] +extern "C" { + /// A VC Credential. Either {@link Credential} or {@link CredentialV2}. + #[derive(Clone)] + #[wasm_bindgen(typescript_type = "Credential | CredentialV2")] + pub type CredentialAny; +} + +impl CredentialAny { + pub(crate) fn try_to_dyn_credential(&self) -> Result + Sync>, JsValue> { + serde_wasm_bindgen::from_value::(self.clone().into()) + .map(|c| Box::new(c) as Box + Sync>) + .or_else(|_| { + serde_wasm_bindgen::from_value::(self.clone().into()) + .map(|c| Box::new(c) as Box + Sync>) + }) + .map_err(|e| e.into()) + } +} diff --git a/bindings/wasm/identity_wasm/src/did/wasm_core_document.rs b/bindings/wasm/identity_wasm/src/did/wasm_core_document.rs index 9ca82769b4..9d1fba225c 100644 --- a/bindings/wasm/identity_wasm/src/did/wasm_core_document.rs +++ b/bindings/wasm/identity_wasm/src/did/wasm_core_document.rs @@ -17,8 +17,8 @@ use crate::common::RecordStringAny; use crate::common::UDIDUrlQuery; use crate::common::UOneOrManyNumber; use crate::credential::ArrayCoreDID; +use crate::credential::CredentialAny; use crate::credential::UnknownCredential; -use crate::credential::WasmCredential; use crate::credential::WasmJws; use crate::credential::WasmJwt; use crate::credential::WasmPresentation; @@ -45,7 +45,6 @@ use identity_iota::core::OneOrMany; use identity_iota::core::OneOrSet; use identity_iota::core::OrderedSet; use identity_iota::core::Url; -use identity_iota::credential::Credential; use identity_iota::credential::JwtPresentationOptions; use identity_iota::credential::Presentation; use identity_iota::credential::RevocationDocumentExt; @@ -706,14 +705,14 @@ impl WasmCoreDocument { &self, storage: &WasmStorage, fragment: String, - credential: &WasmCredential, + credential: &CredentialAny, options: &WasmJwsSignatureOptions, custom_claims: Option, ) -> Result { let storage_clone: Rc = storage.0.clone(); let options_clone: JwsSignatureOptions = options.0.clone(); let document_lock_clone: Rc = self.0.clone(); - let credential_clone: Credential = credential.0.clone(); + let credential_clone = credential.try_to_dyn_credential()?; let custom: Option = custom_claims .map(|claims| claims.into_serde().wasm_result()) .transpose()?; @@ -721,7 +720,7 @@ impl WasmCoreDocument { document_lock_clone .read() .await - .create_credential_jwt(&credential_clone, &storage_clone, &fragment, &options_clone, custom) + .create_credential_jwt(&*credential_clone, &storage_clone, &fragment, &options_clone, custom) .await .wasm_result() .map(WasmJwt::new) diff --git a/bindings/wasm/identity_wasm/src/iota/iota_document.rs b/bindings/wasm/identity_wasm/src/iota/iota_document.rs index e6aea23780..4b96a7511d 100644 --- a/bindings/wasm/identity_wasm/src/iota/iota_document.rs +++ b/bindings/wasm/identity_wasm/src/iota/iota_document.rs @@ -9,7 +9,6 @@ use identity_iota::core::OneOrMany; use identity_iota::core::OrderedSet; use identity_iota::core::Timestamp; use identity_iota::core::Url; -use identity_iota::credential::Credential; use identity_iota::credential::JwtPresentationOptions; use identity_iota::credential::Presentation; @@ -41,6 +40,7 @@ use crate::common::RecordStringAny; use crate::common::UDIDUrlQuery; use crate::common::UOneOrManyNumber; use crate::common::WasmTimestamp; +use crate::credential::CredentialAny; use crate::credential::PromiseJpt; use crate::credential::UnknownCredential; use crate::credential::WasmCredential; @@ -727,14 +727,14 @@ impl WasmIotaDocument { &self, storage: &WasmStorage, fragment: String, - credential: &WasmCredential, + credential: &CredentialAny, options: &WasmJwsSignatureOptions, custom_claims: Option, ) -> Result { let storage_clone: Rc = storage.0.clone(); let options_clone: JwsSignatureOptions = options.0.clone(); let document_lock_clone: Rc = self.0.clone(); - let credential_clone: Credential = credential.0.clone(); + let credential_clone = credential.try_to_dyn_credential()?; let custom: Option = custom_claims .map(|claims| claims.into_serde().wasm_result()) .transpose()?; @@ -742,7 +742,7 @@ impl WasmIotaDocument { document_lock_clone .read() .await - .create_credential_jwt(&credential_clone, &storage_clone, &fragment, &options_clone, custom) + .create_credential_jwt(&*credential_clone, &storage_clone, &fragment, &options_clone, custom) .await .wasm_result() .map(WasmJwt::new) diff --git a/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs b/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs index 6e912e885e..42f4bc892c 100644 --- a/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs +++ b/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs @@ -43,7 +43,7 @@ impl WasmSdObjectEncoder { /// "claim2": ["val_1", "val_2"] /// } /// ``` - /// + /// /// Path "/id" conceals `"id": "did:value"` /// Path "/claim1/abc" conceals `"abc": true` /// Path "/claim2/0" conceals `val_1` diff --git a/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs index 726e9b0cb6..93c5657a6a 100644 --- a/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs +++ b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs @@ -4,6 +4,7 @@ use crate::credential::Credential; use crate::credential::Issuer; use crate::credential::Subject; +use crate::credential::credential_v2::Credential as CredentialV2; use crate::domain_linkage::DomainLinkageConfiguration; use crate::error::Result; use crate::Error; @@ -110,6 +111,49 @@ impl DomainLinkageCredentialBuilder { proof: None, }) } + + /// Returns a new VC Data Model 2.0 `Credential` based on the `DomainLinkageCredentialBuilder` configuration. + pub fn build_v2(self) -> Result> { + let origin: Url = self.origin.ok_or(Error::MissingOrigin)?; + if origin.domain().is_none() { + return Err(Error::DomainLinkageError( + "origin must be a domain with http(s) scheme".into(), + )); + } + if !url_only_includes_origin(&origin) { + return Err(Error::DomainLinkageError( + "origin must not contain any path, query or fragment".into(), + )); + } + + let mut properties: Object = Object::new(); + properties.insert("origin".into(), origin.into_string().into()); + let issuer: Url = self.issuer.ok_or(Error::MissingIssuer)?; + + Ok(CredentialV2 { + context: OneOrMany::Many(vec![ + Credential::::base_context().clone(), + DomainLinkageConfiguration::well_known_context().clone(), + ]), + id: None, + types: OneOrMany::Many(vec![ + Credential::::base_type().to_owned(), + DomainLinkageConfiguration::domain_linkage_type().to_owned(), + ]), + credential_subject: OneOrMany::One(Subject::with_id_and_properties(issuer.clone(), properties)), + issuer: Issuer::Url(issuer), + valid_from: self.issuance_date.unwrap_or_else(Timestamp::now_utc), + valid_until: Some(self.expiration_date.ok_or(Error::MissingExpirationDate)?), + credential_status: None, + credential_schema: Vec::new().into(), + refresh_service: Vec::new().into(), + terms_of_use: Vec::new().into(), + evidence: Vec::new().into(), + non_transferable: None, + properties: Object::new(), + proof: None, + }) + } } #[cfg(test)] diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs index a8ac93159d..73f47a35fb 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs @@ -176,11 +176,40 @@ impl JwtCredentialValidator { Self::verify_signature_with_verifier(&self.0, credential, trusted_issuers, options) } + /// Decode and verify the JWS signature of a [Credential](crate::credential::credential_v2::Credential) issued as a JWT using the DID Document of a trusted + /// issuer. + /// + /// A [`DecodedJwtCredentialV2`] is returned upon success. + /// + /// # Warning + /// The caller must ensure that the DID Documents of the trusted issuers are up-to-date. + /// + /// ## Proofs + /// Only the JWS signature is verified. If the [Credential](crate::credential::credential_v2::Credential) contains a `proof` property this will not be verified + /// by this method. + /// + /// # Errors + /// This method immediately returns an error if + /// the credential issuer' url cannot be parsed to a DID belonging to one of the trusted issuers. Otherwise an attempt + /// to verify the credential's signature will be made and an error is returned upon failure. + pub fn verify_signature_v2( + &self, + credential: &Jwt, + trusted_issuers: &[DOC], + options: &JwsVerificationOptions, + ) -> Result, JwtValidationError> + where + T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, + DOC: AsRef, + { + Self::verify_signature_with_verifier_v2(&self.0, credential, trusted_issuers, options) + } + // This method takes a slice of issuer's instead of a single issuer in order to better accommodate presentation // validation. It also validates the relationship between a holder and the credential subjects when // `relationship_criterion` is Some. pub(crate) fn validate_decoded_credential( - credential: &impl CredentialT, + credential: &dyn CredentialT, issuers: &[DOC], options: &JwtCredentialValidationOptions, fail_fast: FailFast, diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs index e82e28a8c7..da54cbd246 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator_utils.rs @@ -31,7 +31,7 @@ impl JwtCredentialValidatorUtils { /// /// # Warning /// This does not validate against the credential's schema nor the structure of the subject claims. - pub fn check_structure(credential: &impl CredentialT) -> ValidationUnitResult { + pub fn check_structure(credential: &dyn CredentialT) -> ValidationUnitResult { // Ensure the base context is present and in the correct location match credential.context().get(0) { Some(context) if context == credential.base_context() => {} @@ -68,7 +68,7 @@ impl JwtCredentialValidatorUtils { /// Validate that the [`Credential`] expires on or after the specified [`Timestamp`]. pub fn check_expires_on_or_after( - credential: &impl CredentialT, + credential: &dyn CredentialT, timestamp: Timestamp, ) -> ValidationUnitResult { match credential.valid_until() { @@ -79,7 +79,7 @@ impl JwtCredentialValidatorUtils { /// Validate that the [`Credential`] is issued on or before the specified [`Timestamp`]. pub fn check_issued_on_or_before( - credential: &impl CredentialT, + credential: &dyn CredentialT, timestamp: Timestamp, ) -> ValidationUnitResult { if credential.valid_from() <= timestamp { @@ -92,7 +92,7 @@ impl JwtCredentialValidatorUtils { /// Validate that the relationship between the `holder` and the credential subjects is in accordance with /// `relationship`. pub fn check_subject_holder_relationship( - credential: &impl CredentialT, + credential: &dyn CredentialT, holder: &Url, relationship: SubjectHolderRelationship, ) -> ValidationUnitResult { @@ -122,7 +122,7 @@ impl JwtCredentialValidatorUtils { /// Only supports `StatusList2021`. #[cfg(feature = "status-list-2021")] pub fn check_status_with_status_list_2021( - credential: &impl CredentialT, + credential: &dyn CredentialT, status_list_credential: &StatusList2021Credential, status_check: crate::validator::StatusCheck, ) -> ValidationUnitResult { @@ -162,7 +162,7 @@ impl JwtCredentialValidatorUtils { /// Only supports `RevocationBitmap2022`. #[cfg(feature = "revocation-bitmap")] pub fn check_status, T>( - credential: &impl CredentialT, + credential: &dyn CredentialT, trusted_issuers: &[DOC], status_check: crate::validator::StatusCheck, ) -> ValidationUnitResult { @@ -229,7 +229,7 @@ impl JwtCredentialValidatorUtils { /// /// Fails if the issuer field is not a valid DID. pub fn extract_issuer( - credential: &impl CredentialT, + credential: &dyn CredentialT, ) -> std::result::Result where D: DID, diff --git a/identity_storage/src/storage/jwk_document_ext.rs b/identity_storage/src/storage/jwk_document_ext.rs index 9888efd905..872ac888c4 100644 --- a/identity_storage/src/storage/jwk_document_ext.rs +++ b/identity_storage/src/storage/jwk_document_ext.rs @@ -106,7 +106,7 @@ pub trait JwkDocumentExt: private::Sealed { /// The `custom_claims` can be used to set additional claims on the resulting JWT. async fn create_credential_jwt( &self, - credential: &(impl CredentialT + Sync), + credential: &(dyn CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, @@ -440,7 +440,7 @@ impl JwkDocumentExt for CoreDocument { async fn create_credential_jwt( &self, - credential: &(impl CredentialT + Sync), + credential: &(dyn CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, @@ -592,7 +592,7 @@ mod iota_document { async fn create_credential_jwt( &self, - credential: &(impl CredentialT + Sync), + credential: &(dyn CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, From db397edc6055a6a972e14bcf2b3a6ba96c878061 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Thu, 6 Nov 2025 14:53:27 +0100 Subject: [PATCH 6/8] fmt --- .../domain_linkage/domain_linkage_credential_builder.rs | 2 +- .../jwt_credential_validation/jwt_credential_validator.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs index 93c5657a6a..61fd62cc7d 100644 --- a/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs +++ b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs @@ -1,10 +1,10 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use crate::credential::credential_v2::Credential as CredentialV2; use crate::credential::Credential; use crate::credential::Issuer; use crate::credential::Subject; -use crate::credential::credential_v2::Credential as CredentialV2; use crate::domain_linkage::DomainLinkageConfiguration; use crate::error::Result; use crate::Error; diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs index 73f47a35fb..c7e01f94e4 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs @@ -176,8 +176,8 @@ impl JwtCredentialValidator { Self::verify_signature_with_verifier(&self.0, credential, trusted_issuers, options) } - /// Decode and verify the JWS signature of a [Credential](crate::credential::credential_v2::Credential) issued as a JWT using the DID Document of a trusted - /// issuer. + /// Decode and verify the JWS signature of a [Credential](crate::credential::credential_v2::Credential) issued as a + /// JWT using the DID Document of a trusted issuer. /// /// A [`DecodedJwtCredentialV2`] is returned upon success. /// @@ -185,8 +185,8 @@ impl JwtCredentialValidator { /// The caller must ensure that the DID Documents of the trusted issuers are up-to-date. /// /// ## Proofs - /// Only the JWS signature is verified. If the [Credential](crate::credential::credential_v2::Credential) contains a `proof` property this will not be verified - /// by this method. + /// Only the JWS signature is verified. If the [Credential](crate::credential::credential_v2::Credential) contains a + /// `proof` property this will not be verified by this method. /// /// # Errors /// This method immediately returns an error if From c5c19637cd509b59c42ca994c246323df6913119 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Thu, 6 Nov 2025 14:58:58 +0100 Subject: [PATCH 7/8] format wasm --- .../jwt_credential_validation/decoded_jwt_credential.rs | 6 ++++-- bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs b/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs index 14efa4b77d..39b48c4b79 100644 --- a/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs +++ b/bindings/wasm/identity_wasm/src/credential/jwt_credential_validation/decoded_jwt_credential.rs @@ -1,11 +1,13 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use identity_iota::credential::{DecodedJwtCredential, DecodedJwtCredentialV2}; +use identity_iota::credential::DecodedJwtCredential; +use identity_iota::credential::DecodedJwtCredentialV2; use wasm_bindgen::prelude::*; use crate::common::RecordStringAny; -use crate::credential::{WasmCredential, WasmCredentialV2}; +use crate::credential::WasmCredential; +use crate::credential::WasmCredentialV2; use crate::jose::WasmJwsHeader; /// A cryptographically verified and decoded Credential. diff --git a/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs b/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs index 42f4bc892c..6e912e885e 100644 --- a/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs +++ b/bindings/wasm/identity_wasm/src/sd_jwt/encoder.rs @@ -43,7 +43,7 @@ impl WasmSdObjectEncoder { /// "claim2": ["val_1", "val_2"] /// } /// ``` - /// + /// /// Path "/id" conceals `"id": "did:value"` /// Path "/claim1/abc" conceals `"abc": true` /// Path "/claim2/0" conceals `val_1` From bc72266579b3e0806f2065e77c465b148dc1e15f Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Thu, 6 Nov 2025 15:42:52 +0100 Subject: [PATCH 8/8] fix wasm attempt --- bindings/wasm/identity_wasm/src/credential/mod.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bindings/wasm/identity_wasm/src/credential/mod.rs b/bindings/wasm/identity_wasm/src/credential/mod.rs index 45f9e6413c..51705dc8a2 100644 --- a/bindings/wasm/identity_wasm/src/credential/mod.rs +++ b/bindings/wasm/identity_wasm/src/credential/mod.rs @@ -56,14 +56,18 @@ extern "C" { #[derive(Clone)] #[wasm_bindgen(typescript_type = "Credential | CredentialV2")] pub type CredentialAny; + + #[wasm_bindgen(method, js_name = toJSON)] + pub fn to_json(this: &CredentialAny) -> JsValue; } impl CredentialAny { pub(crate) fn try_to_dyn_credential(&self) -> Result + Sync>, JsValue> { - serde_wasm_bindgen::from_value::(self.clone().into()) + let json_repr = self.to_json(); + serde_wasm_bindgen::from_value::(json_repr.clone()) .map(|c| Box::new(c) as Box + Sync>) .or_else(|_| { - serde_wasm_bindgen::from_value::(self.clone().into()) + serde_wasm_bindgen::from_value::(json_repr) .map(|c| Box::new(c) as Box + Sync>) }) .map_err(|e| e.into())