From a70bdf0cce29be0761538741cd02d4f796406300 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Tue, 28 Oct 2025 10:20:01 +0100 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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()) From 740ccbf4a05d1d10641e966f4bd8b458255d01e5 Mon Sep 17 00:00:00 2001 From: Enrico Marconi <31142849+UMR1352@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:51:30 +0100 Subject: [PATCH 09/15] Apply suggestions from code review Co-authored-by: wulfraem --- identity_credential/src/credential/credential_v2.rs | 2 +- .../jwt_credential_validation/jwt_credential_validator.rs | 4 ++-- .../jwt_credential_validator_utils.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/identity_credential/src/credential/credential_v2.rs b/identity_credential/src/credential/credential_v2.rs index 4201c3d330..680f3b6a46 100644 --- a/identity_credential/src/credential/credential_v2.rs +++ b/identity_credential/src/credential/credential_v2.rs @@ -66,7 +66,7 @@ pub struct Credential { /// 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. + /// The latest point in time at which the `Credential` should 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`. 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 c7e01f94e4..0b04e625f1 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 @@ -97,8 +97,8 @@ impl JwtCredentialValidator { /// /// The following properties are validated according to `options`: /// - the issuer's signature on the JWS, - /// - the expiration date, - /// - the issuance date, + /// - the date and time the credential becomes valid, + /// - the date and time the credential ceases to be valid, /// - the semantic structure. /// /// # Warning 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 da54cbd246..2f16fb787f 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 @@ -66,7 +66,7 @@ impl JwtCredentialValidatorUtils { Ok(()) } - /// Validate that the [`Credential`] expires on or after the specified [`Timestamp`]. + /// Validate that the [`Credential`] expires after the specified [`Timestamp`]. pub fn check_expires_on_or_after( credential: &dyn CredentialT, timestamp: Timestamp, From 00e7682b98aa33f4f3edc0c3f34ac424807153c0 Mon Sep 17 00:00:00 2001 From: Enrico Marconi <31142849+UMR1352@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:30:01 +0100 Subject: [PATCH 10/15] Update bindings/wasm/identity_wasm/src/credential/credential_v2.rs Co-authored-by: wulfraem --- bindings/wasm/identity_wasm/src/credential/credential_v2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs index 745bf40f8c..ec52ab4509 100644 --- a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs +++ b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs @@ -134,7 +134,7 @@ impl WasmCredentialV2 { WasmTimestamp::from(self.0.valid_from) } - /// Returns a copy of the timestamp of when the {@link Credential} should no longer be considered valid. + /// Returns a copy of the latest point in time at which the {@link Credential} should be considered valid. #[wasm_bindgen(js_name = "validUntil")] pub fn valid_until(&self) -> Option { self.0.valid_until.map(WasmTimestamp::from) From d57fb7e863154f48516f6d45e232a98db5e9421d Mon Sep 17 00:00:00 2001 From: Enrico Marconi <31142849+UMR1352@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:30:13 +0100 Subject: [PATCH 11/15] Update bindings/wasm/identity_wasm/src/credential/credential_v2.rs Co-authored-by: wulfraem --- bindings/wasm/identity_wasm/src/credential/credential_v2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs index ec52ab4509..e34e3c360e 100644 --- a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs +++ b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs @@ -284,7 +284,7 @@ pub(crate) struct ICredentialHelperV2 { /// 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. + /// The latest point in time at which the {@link Credential} should be considered valid. #[typescript(name = "validUntil", type = "Timestamp")] valid_until: Option, /// Information used to determine the current status of the {@link Credential}. From 03c37aeb222e4b5e9eecc51fd372151d6c2c68a4 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Fri, 14 Nov 2025 13:47:50 +0100 Subject: [PATCH 12/15] re-export v2 Credential as CredentialV2 --- bindings/wasm/identity_wasm/src/credential/credential_v2.rs | 2 +- bindings/wasm/identity_wasm/src/credential/mod.rs | 2 +- examples/0_basic/9_vc_v2.rs | 4 ++-- identity_credential/src/credential/builder.rs | 2 +- identity_credential/src/credential/mod.rs | 4 ++-- .../src/domain_linkage/domain_linkage_credential_builder.rs | 2 +- .../jwt_credential_validation/decoded_jwt_credential.rs | 2 +- .../jwt_credential_validation/jwt_credential_validator.rs | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs index e34e3c360e..6da8e6bd56 100644 --- a/bindings/wasm/identity_wasm/src/credential/credential_v2.rs +++ b/bindings/wasm/identity_wasm/src/credential/credential_v2.rs @@ -6,8 +6,8 @@ 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::CredentialV2; use identity_iota::credential::DomainLinkageCredentialBuilder; use identity_iota::credential::Evidence; use identity_iota::credential::Issuer; diff --git a/bindings/wasm/identity_wasm/src/credential/mod.rs b/bindings/wasm/identity_wasm/src/credential/mod.rs index 51705dc8a2..b603c53db6 100644 --- a/bindings/wasm/identity_wasm/src/credential/mod.rs +++ b/bindings/wasm/identity_wasm/src/credential/mod.rs @@ -4,9 +4,9 @@ #![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 identity_iota::credential::CredentialV2; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; diff --git a/examples/0_basic/9_vc_v2.rs b/examples/0_basic/9_vc_v2.rs index 0bd9f27014..1b14e74021 100644 --- a/examples/0_basic/9_vc_v2.rs +++ b/examples/0_basic/9_vc_v2.rs @@ -25,8 +25,8 @@ 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::CredentialV2; use identity_iota::credential::FailFast; use identity_iota::credential::Subject; use identity_iota::did::DID; @@ -55,7 +55,7 @@ async fn main() -> anyhow::Result<()> { }))?; // Build credential using subject above and issuer. - let credential: Credential = CredentialBuilder::default() + let credential: CredentialV2 = CredentialBuilder::default() .id(Url::parse("https://example.edu/credentials/3732")?) .issuer(Url::parse(issuer_document.id().as_str())?) .type_("UniversityDegreeCredential") diff --git a/identity_credential/src/credential/builder.rs b/identity_credential/src/credential/builder.rs index 1f5ce5f2f6..d19a9181c6 100644 --- a/identity_credential/src/credential/builder.rs +++ b/identity_credential/src/credential/builder.rs @@ -7,8 +7,8 @@ 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::CredentialV2; use crate::credential::Evidence; use crate::credential::Issuer; use crate::credential::Policy; diff --git a/identity_credential/src/credential/mod.rs b/identity_credential/src/credential/mod.rs index f5d75ba6e8..4ec5a17a66 100644 --- a/identity_credential/src/credential/mod.rs +++ b/identity_credential/src/credential/mod.rs @@ -7,8 +7,7 @@ mod builder; mod credential; -/// VC Data Model 2.0 implementation. -pub mod credential_v2; +mod credential_v2; mod evidence; mod issuer; #[cfg(feature = "jpt-bbs-plus")] @@ -57,6 +56,7 @@ pub use self::revocation_bitmap_status::RevocationBitmapStatus; pub use self::schema::Schema; pub use self::status::Status; pub use self::subject::Subject; +pub use credential_v2::Credential as CredentialV2; #[cfg(feature = "validator")] pub(crate) use self::jwt_serialization::CredentialJwtClaims; 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 61fd62cc7d..e71ebb645f 100644 --- a/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs +++ b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs @@ -1,8 +1,8 @@ // 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::CredentialV2; use crate::credential::Issuer; use crate::credential::Subject; use crate::domain_linkage::DomainLinkageConfiguration; 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 1428e3a231..981aac955a 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,8 +1,8 @@ // 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::CredentialV2; use identity_core::common::Object; use identity_verification::jose::jws::JwsHeader; 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 0b04e625f1..5370f4cd0d 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 @@ -202,7 +202,7 @@ impl JwtCredentialValidator { T: ToOwned + serde::Serialize + serde::de::DeserializeOwned, DOC: AsRef, { - Self::verify_signature_with_verifier_v2(&self.0, credential, trusted_issuers, options) + 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 From ccf7d76c411a2c4138cac69e5ea31ccb919e4066 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Wed, 19 Nov 2025 13:22:43 +0100 Subject: [PATCH 13/15] fix merge issues --- identity_credential/src/validator/options.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/identity_credential/src/validator/options.rs b/identity_credential/src/validator/options.rs index c6703f7e13..80d3dcdd3a 100644 --- a/identity_credential/src/validator/options.rs +++ b/identity_credential/src/validator/options.rs @@ -8,7 +8,6 @@ use serde::Serialize; /// [`credentialStatus`](https://www.w3.org/TR/vc-data-model/#status). #[derive(Debug, Clone, Copy, PartialEq, Eq, serde_repr::Serialize_repr, serde_repr::Deserialize_repr, Default)] #[repr(u8)] -#[derive(Default)] pub enum StatusCheck { /// Validate the status if supported, reject any unsupported /// [`credentialStatus`](https://www.w3.org/TR/vc-data-model/#status) types. @@ -31,7 +30,6 @@ pub enum StatusCheck { // 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, Default)] #[repr(u8)] -#[derive(Default)] 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 From 642837ee702d3d3c3fcc499f81beb777c32a2630 Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Mon, 24 Nov 2025 16:34:18 +0100 Subject: [PATCH 14/15] `DataUrl` implementation --- identity_core/Cargo.toml | 1 + identity_core/src/common/data_url.rs | 209 +++++++++++++++++++++++++++ identity_core/src/common/mod.rs | 2 + 3 files changed, 212 insertions(+) create mode 100644 identity_core/src/common/data_url.rs diff --git a/identity_core/Cargo.toml b/identity_core/Cargo.toml index ae884fc6bd..65091effd0 100644 --- a/identity_core/Cargo.toml +++ b/identity_core/Cargo.toml @@ -16,6 +16,7 @@ description = "The core traits and types for the identity-rs library." deranged = { version = ">=0.4.0, <0.4.1", default-features = false } iota-caip = { git = "https://github.com/iotaledger/iota-caip.git", optional = true, default-features = false, features = ["iota", "resolver"] } multibase = { version = "0.9", default-features = false, features = ["std"] } +nom = "8.0.0" product_common.workspace = true serde = { workspace = true, features = ["std"] } serde_json = { workspace = true, features = ["std"] } diff --git a/identity_core/src/common/data_url.rs b/identity_core/src/common/data_url.rs new file mode 100644 index 0000000000..41e3f55802 --- /dev/null +++ b/identity_core/src/common/data_url.rs @@ -0,0 +1,209 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::{fmt::Display, str::FromStr}; + +use crate::common::Url; + +const DEFAULT_MIME_TYPE: &'static str = "text/plain"; + +/// An URL using the "data" scheme, according to [RFC2397](https://datatracker.ietf.org/doc/html/rfc2397). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DataUrl { + serialized: Box, + start_of_data: u32, + base64: bool, +} + +impl AsRef for DataUrl { + fn as_ref(&self) -> &str { + &self.serialized + } +} + +impl DataUrl { + /// Return the string representation of this [DataUrl]. + pub const fn as_str(&self) -> &str { + &self.serialized + } + + /// Parses a [DataUrl] from the given string input. + /// # Example + /// ``` + /// # use identity_core::common::DataUrl; + /// # use identity_core::common::InvalidDataUrl; + /// # + /// # fn main() -> Result<(), InvalidDataUrl> { + /// let data_url = DataUrl::parse("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==")?; + /// assert!(data_url.is_base64()); + /// assert_eq!(data_url.mediatype(), "text/plain"); + /// assert_eq!(data_url.encoded_data(), "SGVsbG8sIFdvcmxkIQ=="); + /// # Ok(()) + /// # } + /// ``` + pub fn parse(input: &str) -> Result { + use nom::combinator::all_consuming; + use nom::Parser as _; + + let (_, data_url) = all_consuming(parsers::data_url) + .parse(input) + .map_err(|_| InvalidDataUrl {})?; + Ok(data_url) + } + + /// Returns whether this [DataUrl] has its `base64` flag set, e.g. "data:image/gif". + pub const fn is_base64(&self) -> bool { + self.base64 + } + + /// Returns the string representation of the data encoded within this [DataUrl]. + pub fn encoded_data(&self) -> &str { + let idx = self.start_of_data as usize; + &self.as_str()[idx..] + } + + /// Returns the string representation of the MIME type of the data encoded within + /// this [DataUrl]. The returned string also contains the type's parameters if any. + /// ## Notes + /// When a [DataUrl] omits the MIME type (e.g. "data:,A%20brief%20note"), this method + /// returns the default MIME type "text/plain;charset=US-ASCII", instead of the empty + /// string. + /// # Example + pub fn mediatype(&self) -> &str { + let start = "data:".len(); + let end = self.start_of_data as usize + - 1 // ',' + - self.base64 as usize * ";base64".len(); // optional ";base64" + + let mime = &self.serialized[start..end]; + if mime.is_empty() { + DEFAULT_MIME_TYPE + } else { + mime + } + } +} + +impl Display for DataUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.serialized) + } +} + +impl FromStr for DataUrl { + type Err = InvalidDataUrl; + + fn from_str(s: &str) -> Result { + DataUrl::parse(s) + } +} + +impl From for Url { + fn from(data_url: DataUrl) -> Self { + Url::parse(data_url.as_str()).expect("DataUrl is always a valid Url") + } +} + +/// Error indicating that a given string is not a valid data URL. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct InvalidDataUrl {} + +impl Display for InvalidDataUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid data URL") + } +} + +impl std::error::Error for InvalidDataUrl {} +mod parsers { + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::bytes::complete::take_while1; + use nom::bytes::complete::take_while_m_n; + #[cfg(test)] + use nom::combinator::all_consuming; + use nom::combinator::opt; + use nom::combinator::recognize; + use nom::multi::many1_count; + use nom::sequence::preceded; + use nom::sequence::separated_pair; + use nom::IResult; + use nom::Parser; + + use super::DataUrl; + + pub(super) fn data_url(input: &str) -> IResult<&str, DataUrl> { + let (rem, (_type, base64, data)) = preceded( + tag("data:"), + ( + opt(mediatype), + opt(tag(";base64")).map(|opt| opt.is_some()), + preceded(tag(","), uri_char1), + ), + ) + .parse(input)?; + + let consumed = input.len() - rem.len(); + let serialized = input[..consumed].to_owned().into_boxed_str(); + let start_of_data = (consumed - data.len()) as u32; + + Ok(( + rem, + DataUrl { + serialized, + start_of_data, + base64, + }, + )) + } + + fn mediatype(input: &str) -> IResult<&str, &str> { + let type_ = separated_pair(media_char1, tag("/"), media_char1); + let parameters = many1_count(preceded(tag(";"), separated_pair(media_char1, tag("="), media_char1))); + + recognize((type_, opt(parameters))).parse(input) + } + + fn uri_char1(input: &str) -> IResult<&str, &str> { + let reserved = take_while1(|c: char| ";/?:@&=+$,".contains(c)); + let unreserved = take_while1(|c: char| "-_.!~*'(|)".contains(c) || c.is_ascii_alphanumeric()); + let escaped = recognize(percent_escaped); + + recognize(many1_count(alt((reserved, unreserved, escaped)))).parse(input) + } + + fn media_char1(input: &str) -> IResult<&str, &str> { + take_while1(|c: char| c.is_ascii_alphanumeric() || "-_.+".contains(c))(input) + } + + fn percent_escaped(input: &str) -> IResult<&str, u8> { + preceded(tag("%"), take_while_m_n(2, 2, |c: char| c.is_ascii_hexdigit())) + .map_res(|hex_byte| u8::from_str_radix(hex_byte, 16)) + .parse(input) + } + + #[cfg(test)] + #[test] + fn mediatype_parser() { + all_consuming(mediatype).parse("text/plain").unwrap(); + all_consuming(mediatype).parse("application/vc+jwt").unwrap(); + all_consuming(mediatype).parse("video/mp4").unwrap(); + all_consuming(mediatype).parse("text/plain;charset=us-ascii").unwrap(); + } + + #[cfg(test)] + #[test] + fn data_url_parser() { + all_consuming(data_url).parse("data:text/plain,hello").unwrap(); + all_consuming(data_url) + .parse("data:text/plain;charset=us-ascii,hello%20world") + .unwrap(); + all_consuming(data_url).parse("data:,hello%20world").unwrap(); + all_consuming(data_url).parse("data:application/vc+jwt,ey").unwrap(); + let (_, data_url) = all_consuming(data_url) + .parse("data:application/vc+jwt;base64,ey") + .unwrap(); + assert!(data_url.is_base64()); + } +} diff --git a/identity_core/src/common/mod.rs b/identity_core/src/common/mod.rs index 36a224d618..9e26f4f01c 100644 --- a/identity_core/src/common/mod.rs +++ b/identity_core/src/common/mod.rs @@ -12,11 +12,13 @@ pub use self::single_struct_error::*; pub use self::timestamp::Duration; pub use self::timestamp::Timestamp; pub use self::url::Url; +pub use self::data_url::*; pub use product_common::object::Object; pub use product_common::object::Value; pub use string_or_url::StringOrUrl; mod context; +mod data_url; mod key_comparable; mod one_or_many; mod one_or_set; From 8ac8b671a355e6c092efa93d18e2a26a54a4c2cc Mon Sep 17 00:00:00 2001 From: Enrico Marconi Date: Tue, 25 Nov 2025 12:57:08 +0100 Subject: [PATCH 15/15] `EnvelopedVerifiableCredential` implementation --- identity_core/src/common/context.rs | 2 +- identity_core/src/common/data_url.rs | 28 ++- identity_core/src/common/mod.rs | 2 +- identity_credential/Cargo.toml | 1 + .../src/credential/credential_v2.rs | 2 +- .../src/credential/enveloped_credential.rs | 208 ++++++++++++++++++ identity_credential/src/credential/mod.rs | 2 + 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 identity_credential/src/credential/enveloped_credential.rs diff --git a/identity_core/src/common/context.rs b/identity_core/src/common/context.rs index 96c415c9e2..4d938b3846 100644 --- a/identity_core/src/common/context.rs +++ b/identity_core/src/common/context.rs @@ -15,7 +15,7 @@ use crate::common::Url; /// A reference to a JSON-LD context /// /// [More Info](https://www.w3.org/TR/vc-data-model/#contexts) -#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Hash)] #[serde(untagged)] pub enum Context { /// A JSON-LD context expressed as a Url. diff --git a/identity_core/src/common/data_url.rs b/identity_core/src/common/data_url.rs index 41e3f55802..024f35cd59 100644 --- a/identity_core/src/common/data_url.rs +++ b/identity_core/src/common/data_url.rs @@ -1,11 +1,14 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::{fmt::Display, str::FromStr}; +use std::fmt::Display; +use std::str::FromStr; + +use serde::Serialize; use crate::common::Url; -const DEFAULT_MIME_TYPE: &'static str = "text/plain"; +const DEFAULT_MIME_TYPE: &str = "text/plain"; /// An URL using the "data" scheme, according to [RFC2397](https://datatracker.ietf.org/doc/html/rfc2397). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -104,6 +107,27 @@ impl From for Url { } } +impl Serialize for DataUrl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.serialized) + } +} + +impl<'de> serde::Deserialize<'de> for DataUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let str = <&str>::deserialize(deserializer)?; + DataUrl::parse(str).map_err(|_| Error::custom("invalid data URL")) + } +} + /// Error indicating that a given string is not a valid data URL. #[derive(Debug, Clone)] #[non_exhaustive] diff --git a/identity_core/src/common/mod.rs b/identity_core/src/common/mod.rs index 9e26f4f01c..d10906dcd7 100644 --- a/identity_core/src/common/mod.rs +++ b/identity_core/src/common/mod.rs @@ -4,6 +4,7 @@ //! Definitions of common types (`Url`, `Timestamp`, JSON types, etc). pub use self::context::Context; +pub use self::data_url::*; pub use self::key_comparable::KeyComparable; pub use self::one_or_many::OneOrMany; pub use self::one_or_set::OneOrSet; @@ -12,7 +13,6 @@ pub use self::single_struct_error::*; pub use self::timestamp::Duration; pub use self::timestamp::Timestamp; pub use self::url::Url; -pub use self::data_url::*; pub use product_common::object::Object; pub use product_common::object::Value; pub use string_or_url::StringOrUrl; diff --git a/identity_credential/Cargo.toml b/identity_credential/Cargo.toml index d40260e08b..82bdf8797a 100644 --- a/identity_credential/Cargo.toml +++ b/identity_credential/Cargo.toml @@ -24,6 +24,7 @@ indexmap = { version = "2.0", default-features = false, features = ["std", "serd itertools = { version = "0.11", default-features = false, features = ["use_std"], optional = true } json-proof-token = { workspace = true, optional = true } jsonschema = { version = "0.19", optional = true, default-features = false } +monostate = "1.0.2" once_cell = { version = "1.18", default-features = false, features = ["std"] } reqwest = { version = "0.12", default-features = false, features = ["default-tls", "json", "stream"], optional = true } roaring = { version = "0.10.2", default-features = false, features = ["serde"], optional = true } diff --git a/identity_credential/src/credential/credential_v2.rs b/identity_credential/src/credential/credential_v2.rs index 680f3b6a46..985c5d6e85 100644 --- a/identity_credential/src/credential/credential_v2.rs +++ b/identity_credential/src/credential/credential_v2.rs @@ -34,7 +34,7 @@ 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> +pub(crate) fn deserialize_vc2_0_context<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { diff --git a/identity_credential/src/credential/enveloped_credential.rs b/identity_credential/src/credential/enveloped_credential.rs new file mode 100644 index 0000000000..8cc378d766 --- /dev/null +++ b/identity_credential/src/credential/enveloped_credential.rs @@ -0,0 +1,208 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; +use std::ops::Deref; + +use identity_core::common::Context; +use identity_core::common::DataUrl; +use identity_core::common::InvalidDataUrl; +use identity_core::common::Object; +use identity_core::common::OneOrMany; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +use crate::credential::credential_v2::deserialize_vc2_0_context; +use crate::credential::CredentialV2; + +const ENVELOPED_VC_TYPE: &str = "EnvelopedVerifiableCredential"; + +fn deserialize_enveloped_vc_type<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use serde::de::Unexpected; + + let str = <&'de str>::deserialize(deserializer)?; + if str == ENVELOPED_VC_TYPE { + Ok(ENVELOPED_VC_TYPE.to_owned().into_boxed_str()) + } else { + Err(Error::invalid_value(Unexpected::Str(str), &ENVELOPED_VC_TYPE)) + } +} + +/// An Enveloped Verifiable Credential as defined in +/// [VC Data Model 2.0](https://www.w3.org/TR/vc-data-model-2.0/#enveloped-verifiable-credentials). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct EnvelopedVc { + /// The set of JSON-LD contexts that apply to this object. + #[serde(rename = "@context", deserialize_with = "deserialize_vc2_0_context")] + context: OneOrMany, + /// [VcDataUrl] containing the actual Verifiable Credential. + pub id: VcDataUrl, + /// The type of this object, which is always "EnvelopedVerifiableCredential". + #[serde(rename = "type", deserialize_with = "deserialize_enveloped_vc_type")] + type_: Box, + /// Additional properties. + #[serde(flatten)] + pub properties: Object, +} + +impl EnvelopedVc { + /// Constructs a new [EnvelopedVc] with the given `id`. + pub fn new(id: VcDataUrl) -> Self { + Self { + context: OneOrMany::One(CredentialV2::<()>::base_context().clone()), + id, + type_: ENVELOPED_VC_TYPE.to_owned().into_boxed_str(), + properties: Object::default(), + } + } + + /// The value of this object's "type" property, which is always "EnvelopedVerifiableCredential". + pub fn type_(&self) -> &str { + &self.type_ + } + + /// The value of this object's "@context" property. + pub fn context(&self) -> &[Context] { + self.context.as_slice() + } + + /// Sets the value of this object's "@context" property. + /// # Notes + /// This method will always ensure the very first context is "https://www.w3.org/ns/credentials/v2" + /// and that no duplicated contexts are present. + /// # Example + /// ``` + /// # use identity_core::common::DataUrl; + /// # use identity_credential::credential::EnvelopedVc; + /// # fn main() -> Result<(), Box> { + /// let mut enveloped_vc = EnvelopedVc::new(DataUrl::parse("data:application/vc,QzVjV...RMjU")?); + /// enveloped_vc.set_context(vec![]); + /// assert_eq!( + /// enveloped_vc.context(), + /// &["https://www.w3.org/ns/credentials/v2"] + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn set_context(&mut self, contexts: impl IntoIterator) { + use itertools::Itertools; + + let contexts = std::iter::once(CredentialV2::<()>::base_context().clone()) + .chain(contexts) + .unique() + .collect_vec(); + debug_assert_eq!(contexts.first(), Some(CredentialV2::<()>::base_context())); + + self.context = contexts.into(); + } +} + +/// A [DataUrl] encoding a VC within it (recognized through the use of the "application/vc" media type) +/// for use as the `id` of an [EnvelopedVc]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct VcDataUrl(DataUrl); + +impl VcDataUrl { + /// Parses the given input string as a [VcDataUrl]. + /// # Example + /// ``` + /// # use identity_credential::credential::{VcDataUrl, VcDataUrlParsingError}; + /// # fn main() -> Result<(), VcDataUrlParsingError> { + /// let plaintext_vc_data_url = VcDataUrl::parse("data:application/vc;base64,eyVjV...RMjU")?; + /// let jwt_vc_data_url = VcDataUrl::parse("data:application/vc+jwt,eyJraWQiO...zhwGfQ")?; + /// let sd_jwt_vc_data_url = VcDataUrl::parse("data:application/vc+sd-jwt,QzVjV...RMjU")?; + /// # Ok(()) + /// # } + /// ``` + pub fn parse(input: &str) -> Result { + let data_url = DataUrl::parse(input)?; + + if data_url.mediatype().starts_with("application/vc") { + Ok(Self(data_url)) + } else { + Err(VcDataUrlParsingError::InvalidMediaType(InvalidMediaType { + got: data_url.mediatype().to_string(), + })) + } + } +} + +impl Deref for VcDataUrl { + type Target = DataUrl; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for VcDataUrl { + type Error = InvalidMediaType; + + fn try_from(value: DataUrl) -> Result { + if value.mediatype().starts_with("application/vc") { + Ok(Self(value)) + } else { + Err(InvalidMediaType { + got: value.mediatype().to_string(), + }) + } + } +} + +impl From for DataUrl { + fn from(value: VcDataUrl) -> Self { + value.0 + } +} + +impl Display for VcDataUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Errors that can occur when parsing a [VcDataUrl]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum VcDataUrlParsingError { + /// The input string did not conform to the [DataUrl] format. + #[error(transparent)] + NotADataUrl(#[from] InvalidDataUrl), + /// The [DataUrl] does not have a valid media type for a VC. + #[error(transparent)] + InvalidMediaType(#[from] InvalidMediaType), +} + +/// Error indicating that a [DataUrl] does not have a valid media type for a VC. +#[derive(Debug, thiserror::Error)] +#[error("invalid media type `{got}`: expected `application/vc` or related media type")] +#[non_exhaustive] +pub struct InvalidMediaType { + /// The invalid media type that was found. + pub got: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_roundtrip() { + let vc_data_url = VcDataUrl::parse("data:application/vc,QzVjV...RMjU").unwrap(); + let enveloped_vc = EnvelopedVc::new(vc_data_url.clone()); + + let serialized = serde_json::to_string(&enveloped_vc).unwrap(); + let deserialized: EnvelopedVc = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.type_(), ENVELOPED_VC_TYPE); + assert_eq!(deserialized.id, vc_data_url); + assert_eq!(deserialized.context(), &[CredentialV2::<()>::base_context().clone()]); + } +} diff --git a/identity_credential/src/credential/mod.rs b/identity_credential/src/credential/mod.rs index 4ec5a17a66..21326bba19 100644 --- a/identity_credential/src/credential/mod.rs +++ b/identity_credential/src/credential/mod.rs @@ -8,6 +8,7 @@ mod builder; mod credential; mod credential_v2; +mod enveloped_credential; mod evidence; mod issuer; #[cfg(feature = "jpt-bbs-plus")] @@ -57,6 +58,7 @@ pub use self::schema::Schema; pub use self::status::Status; pub use self::subject::Subject; pub use credential_v2::Credential as CredentialV2; +pub use enveloped_credential::*; #[cfg(feature = "validator")] pub(crate) use self::jwt_serialization::CredentialJwtClaims;