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..6da8e6bd56 --- /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::CredentialBuilder; +use identity_iota::credential::CredentialV2; +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 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) + } + + /// 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, + /// 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}. + #[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..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 @@ -2,10 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 use identity_iota::credential::DecodedJwtCredential; +use identity_iota::credential::DecodedJwtCredentialV2; use wasm_bindgen::prelude::*; use crate::common::RecordStringAny; use crate::credential::WasmCredential; +use crate::credential::WasmCredentialV2; use crate::jose::WasmJwsHeader; /// A cryptographically verified and decoded Credential. @@ -57,3 +59,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..b603c53db6 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; +use identity_iota::credential::CredentialT; +use identity_iota::credential::CredentialV2; +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,27 @@ 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; + + #[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> { + 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::(json_repr) + .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/examples/0_basic/9_vc_v2.rs b/examples/0_basic/9_vc_v2.rs new file mode 100644 index 0000000000..1b14e74021 --- /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::CredentialBuilder; +use identity_iota::credential::CredentialV2; +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: CredentialV2 = 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 7173b7e169..08d0e9b6b8 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_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/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 new file mode 100644 index 0000000000..024f35cd59 --- /dev/null +++ b/identity_core/src/common/data_url.rs @@ -0,0 +1,233 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; +use std::str::FromStr; + +use serde::Serialize; + +use crate::common::Url; + +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)] +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") + } +} + +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] +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..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; @@ -17,6 +18,7 @@ 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; 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/builder.rs b/identity_credential/src/credential/builder.rs index f95771c500..d19a9181c6 100644 --- a/identity_credential/src/credential/builder.rs +++ b/identity_credential/src/credential/builder.rs @@ -8,6 +8,7 @@ use identity_core::common::Url; use identity_core::common::Value; use crate::credential::Credential; +use crate::credential::CredentialV2; use crate::credential::Evidence; use crate::credential::Issuer; use crate::credential::Policy; @@ -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..985c5d6e85 --- /dev/null +++ b/identity_credential/src/credential/credential_v2.rs @@ -0,0 +1,297 @@ +// 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::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())); + +pub(crate) 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, + /// 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`. + #[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 { + self + .to_json() + .map_err(|err| Error::JwtClaimsSetSerializationError(err.into())) + } +} + +#[cfg(test)] +mod tests { + use identity_verification::jws::Decoder; + + 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(); + } + + #[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"); + } +} 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/jwt_serialization.rs b/identity_credential/src/credential/jwt_serialization.rs index e763a53858..eb85f33506 100644 --- a/identity_credential/src/credential/jwt_serialization.rs +++ b/identity_credential/src/credential/jwt_serialization.rs @@ -115,6 +115,8 @@ 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), @@ -213,12 +215,11 @@ where jti, sub, vc, - custom: _, + .. } = self; let InnerCredential { context, - id: _, types, credential_subject, credential_status, @@ -229,9 +230,7 @@ where non_transferable, properties, proof, - issuance_date: _, - issuer: _, - expiration_date: _, + .. } = vc; Ok(Credential { @@ -348,6 +347,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..21326bba19 100644 --- a/identity_credential/src/credential/mod.rs +++ b/identity_credential/src/credential/mod.rs @@ -7,6 +7,8 @@ mod builder; mod credential; +mod credential_v2; +mod enveloped_credential; 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; @@ -50,8 +57,43 @@ 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; +pub use enveloped_credential::*; #[cfg(feature = "validator")] 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/domain_linkage/domain_linkage_credential_builder.rs b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs index 726e9b0cb6..e71ebb645f 100644 --- a/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs +++ b/identity_credential/src/domain_linkage/domain_linkage_credential_builder.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::credential::Credential; +use crate::credential::CredentialV2; use crate::credential::Issuer; use crate::credential::Subject; use crate::domain_linkage::DomainLinkageConfiguration; @@ -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/decoded_jwt_credential.rs b/identity_credential/src/validator/jwt_credential_validation/decoded_jwt_credential.rs index d2daacd4c2..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 @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::credential::Credential; +use crate::credential::CredentialV2; use identity_core::common::Object; use identity_verification::jose::jws::JwsHeader; @@ -19,3 +20,14 @@ 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, +} 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..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 @@ -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 date and time the credential becomes valid, + /// - the date and time the credential ceases to be valid, + /// - 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 @@ -115,25 +176,53 @@ 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_token: DecodedJwtCredential, + credential: &dyn 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 +265,7 @@ impl JwtCredentialValidator { }; if validation_errors.is_empty() { - Ok(credential_token) + Ok(()) } else { Err(CompoundCredentialValidationError { validation_errors }) } @@ -274,6 +363,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 +452,26 @@ 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 = serde_json::from_slice(&claims) + .map_err(|e| JwtValidationError::CredentialStructure(crate::Error::JwtClaimsSetDeserializationError(e.into())))?; + + Ok(DecodedJwtCredentialV2 { + credential, + header: Box::new(protected), + }) + } } #[cfg(test)] @@ -370,7 +516,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 +526,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 +647,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 +660,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 +681,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..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 @@ -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: &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() => {} + 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) + /// Validate that the [`Credential`] expires after the specified [`Timestamp`]. + pub fn check_expires_on_or_after( + credential: &dyn 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: &dyn 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: &dyn 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: &dyn 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: &dyn 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: &dyn 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/options.rs b/identity_credential/src/validator/options.rs index 17331403e8..80d3dcdd3a 100644 --- a/identity_credential/src/validator/options.rs +++ b/identity_credential/src/validator/options.rs @@ -6,9 +6,8 @@ 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)] -#[derive(Default)] pub enum StatusCheck { /// Validate the status if supported, reject any unsupported /// [`credentialStatus`](https://www.w3.org/TR/vc-data-model/#status) types. @@ -29,9 +28,8 @@ pub enum StatusCheck { /// /// 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)] -#[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 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..872ac888c4 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: &(dyn 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: &(dyn CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions, @@ -591,7 +592,7 @@ mod iota_document { async fn create_credential_jwt( &self, - credential: &Credential, + credential: &(dyn CredentialT + Sync), storage: &Storage, fragment: &str, options: &JwsSignatureOptions,