From c24b4bfe0dcc864d4f944f84838d26f6f17e4684 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Wed, 18 Sep 2024 12:12:43 -0700 Subject: [PATCH] credential type hint method for constraint field --- src/core/input_descriptor.rs | 187 +++++++++++++++++- src/core/presentation_definition.rs | 147 +++++++++++++- .../iso.org.18013.5.1.mdl.json | 67 +++++++ .../multi-credential-array.json | 88 +++++++++ .../multi-credential-pattern.json | 70 +++++++ .../multi-input-credential-enum.json | 155 +++++++++++++++ 6 files changed, 699 insertions(+), 15 deletions(-) create mode 100644 tests/presentation-definition/iso.org.18013.5.1.mdl.json create mode 100644 tests/presentation-definition/multi-credential-array.json create mode 100644 tests/presentation-definition/multi-credential-pattern.json create mode 100644 tests/presentation-definition/multi-input-credential-enum.json diff --git a/src/core/input_descriptor.rs b/src/core/input_descriptor.rs index e1e30a4..645c3b8 100644 --- a/src/core/input_descriptor.rs +++ b/src/core/input_descriptor.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use super::{credential_format::*, presentation_submission::*}; use crate::utils::NonEmptyVec; @@ -135,6 +137,23 @@ impl InputDescriptor { self } + /// Return the format of the input descriptor. + /// + /// The Input Descriptor Object MAY contain a format property. If present, + /// its value MUST be an object with one or more properties matching the registered + /// Claim Format Designations (e.g., jwt, jwt_vc, jwt_vp, etc.). + /// + /// This format property is identical in value signature to the top-level format object, + /// but can be used to specifically constrain submission of a single input to a subset of formats or algorithms. + pub fn format(&self) -> &ClaimFormatMap { + &self.format + } + + /// Return the format designations of the input descriptor as a hash set. + pub fn format_designations(&self) -> HashSet<&ClaimFormatDesignation> { + self.format.keys().collect() + } + /// Set the group of the constraints field. pub fn set_group(mut self, group: Vec) -> Self { self.group = group; @@ -254,16 +273,70 @@ impl InputDescriptor { Ok(()) } - /// Return the format of the input descriptor. + /// Return the humanly readable requested fields of the input descriptor. + pub fn requested_fields(&self) -> Vec { + self.constraints() + .fields() + .iter() + .flat_map(|field| field.requested_fields()) + .collect() + } + + /// Return the credential types of the input descriptor, if any. + pub fn credential_types_hint(&self) -> Vec { + self.constraints() + .fields() + .iter() + .flat_map(|field| field.credential_types_hint()) + .collect() + } + + /// Return the requested fields and the associated credential type(s) of the input descriptor. + pub fn requested_fields_with_credential_types(&self) -> CredentialTypesRequestedFields { + CredentialTypesRequestedFields { + input_descriptor_id: self.id.clone(), + credential_type_hint: self.credential_types_hint(), + requested_fields: self.requested_fields(), + } + } +} + +/// A parsed object containing the credential type(s) and their +/// respective requested fields, parsed from the input descriptor contraints fields. +/// +/// NOTE: This object is not part of the OID4VP specification, but is used to simplify the +/// extraction of the requested fields and credential types from the input descriptor. +/// +/// If the credential types hint is non-empty, then the holder MUST select from the list of +/// credentials that satisfies the requested fields. Otherwise, if the list is empty, the holder +/// may choose to select any credential that satisfies the requested fields. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CredentialTypesRequestedFields { + input_descriptor_id: String, + credential_type_hint: Vec, + requested_fields: Vec, +} + +impl CredentialTypesRequestedFields { + /// Return the input descriptor ID. + pub fn input_descriptor_id(&self) -> &str { + &self.input_descriptor_id + } + + /// Return the credential type hint(s). /// - /// The Input Descriptor Object MAY contain a format property. If present, - /// its value MUST be an object with one or more properties matching the registered - /// Claim Format Designations (e.g., jwt, jwt_vc, jwt_vp, etc.). + /// NOTE: If credential types hint is non-empty, then the holder MUST + /// select from the list of credentials that satisfies the requested fields. /// - /// This format property is identical in value signature to the top-level format object, - /// but can be used to specifically constrain submission of a single input to a subset of formats or algorithms. - pub fn format(&self) -> &ClaimFormatMap { - &self.format + /// Otherwise, if the list is empty, the holder may choose to select any + /// credential that satisfies the requested fields. + pub fn credential_type_hint(&self) -> &[CredentialType] { + &self.credential_type_hint + } + + /// Return the requested fields. + pub fn requested_fields(&self) -> &[String] { + &self.requested_fields } } @@ -588,6 +661,104 @@ impl ConstraintsField { }) .collect() } + + /// Returns the Credential Type(s) found in the constraints field. + /// + /// Note: This is a `hint` in that it is not guaranteed that the credential type + /// can be parsed from the input descriptor. + /// + /// This will return an empty vector if the credential type cannot be parsed. + /// + /// Multiple credentials can be returned if the input descriptor contains a pattern + /// filter that matches multiple credentials. + pub fn credential_types_hint(&self) -> Vec { + // NOTE: There may be other ways to search for a valid the credential type + // that meets the input descriptor constraints. + // + // A more exhaustive search may require parsing each credential to + // check if it contains a certain field, e.g. `firstName`, `familyName`, etc., + // and see if it will satisfy the constraints. + // + // For now, we explicity check the type of the credential if it is present + // in the credential `type` field. + + let mut parsed_credentials = Vec::new(); + + if self + .path + .as_ref() + .iter() + // Check if any of the paths contain a reference to type. + // NOTE: It may not be guaranteed or normative that a `type` field to the path + // for a verifiable credential is present. + .any(|path| path.contains(&"type".to_string())) + { + // Check the filter field to determine the `const` + // value for the credential type, e.g. `iso.org.18013.5.1.mDL`, etc. + if let Some(credential) = self.filter.as_ref().and_then(|filter| { + filter + .get("const") + .and_then(serde_json::Value::as_str) + .map(CredentialType::from) + }) { + parsed_credentials.push(credential); + } + + // The `type` field may be an array with a nested `const` value. + if let Some(credential) = self.filter.as_ref().and_then(|filter| { + filter + .get("contains") + .and_then(|value| value.get("const")) + .and_then(serde_json::Value::as_str) + .map(CredentialType::from) + }) { + parsed_credentials.push(credential); + } + + // The `type` field may be an array with a nested `enum` value. + if let Some(credential) = self.filter.as_ref().and_then(|filter| { + filter + .get("contains") + .and_then(|value| value.get("enum")) + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(CredentialType::from) + .collect::>() + }) + }) { + parsed_credentials.extend(credential); + } + + // Check a pattern for the filter that may include multiple credentials + // that may satisfy the constraints. + if let Some(credentials) = self.filter.as_ref().and_then(|filter| { + filter + .get("pattern") + .and_then(serde_json::Value::as_str) + .map(|pattern| { + // Remove the start (^) and end ($) anchors + let trimmed = pattern.trim_start_matches('^').trim_end_matches('$'); + + // Remove the outer parentheses + let inner = trimmed.trim_start_matches('(').trim_end_matches(')'); + + // Split by the '|' character + inner + .split('|') + .map(|s| s.to_string()) + .collect::>() + }) + }) { + // Found multiple credentials that may satisfy the constraints. + parsed_credentials.extend(credentials); + } + } + + parsed_credentials + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/core/presentation_definition.rs b/src/core/presentation_definition.rs index 2212e70..348ce89 100644 --- a/src/core/presentation_definition.rs +++ b/src/core/presentation_definition.rs @@ -9,6 +9,15 @@ use serde::{Deserialize, Serialize}; use serde_json::Map; use ssi::claims::jwt::VerifiablePresentation; +/// A non-normative mappings of credential type(s) to requested fields. +/// +/// This is used for parsing human-readable requested fields and their associated credential types, +/// if any are provided. +/// +/// This type is not part of the OID4VP specification, but is provided as a part of helper method for presenting +/// information about the presentation definition to the holder. +pub type CredentialTypesRequestedMap = HashMap; + /// A presentation definition is a JSON object that describes the information a [Verifier](https://identity.foundation/presentation-exchange/spec/v2.0.0/#term:verifier) requires of a [Holder](https://identity.foundation/presentation-exchange/spec/v2.0.0/#term:holder). /// /// > Presentation Definitions are objects that articulate what proofs a [Verifier](https://identity.foundation/presentation-exchange/spec/v2.0.0/#term:verifier) requires. @@ -190,14 +199,30 @@ impl PresentationDefinition { pub fn requested_fields(&self) -> Vec { self.input_descriptors .iter() - .flat_map(|input_descriptor| { - input_descriptor - .constraints() - .fields() - .iter() - .map(|constraint| constraint.requested_fields()) + .flat_map(|descriptor| descriptor.requested_fields()) + .collect() + } + + /// Return the credential types requested in the presentation definition, + /// if any. + pub fn credential_types_hint(&self) -> Vec { + self.input_descriptors + .iter() + .flat_map(|descriptor| descriptor.credential_types_hint()) + .collect() + } + + /// Returns a map of the input descriptor ID to the credential type(s) + /// and their requested fields. + pub fn requested_credential_types_map(&self) -> CredentialTypesRequestedMap { + self.input_descriptors + .iter() + .map(|descriptor| { + ( + descriptor.id().to_string(), + descriptor.requested_fields_with_credential_types(), + ) }) - .flatten() .collect() } @@ -371,3 +396,111 @@ pub struct SubmissionRequirementPick { pub min: Option, pub max: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + use anyhow::Result; + + #[test] + fn test_input_descriptor_credential_type() -> Result<()> { + let definition: PresentationDefinition = serde_json::from_str(include_str!( + "../../tests/presentation-definition/iso.org.18013.5.1.mdl.json" + ))?; + + let credentials = definition.credential_types_hint(); + + assert_eq!( + credentials.first(), + Some(&"iso.org.18013.5.1.mDL".to_string()) + ); + + Ok(()) + } + + #[test] + fn test_input_descriptor_multi_credential_types_pattern() -> Result<()> { + let definition: PresentationDefinition = serde_json::from_str(include_str!( + "../../tests/presentation-definition/multi-credential-pattern.json" + ))?; + + let credentials = definition.credential_types_hint(); + + assert!(credentials.contains(&"PassportCredential".into())); + assert!(credentials.contains(&"DriversLicenseCredential".into())); + assert!(credentials.contains(&"NationalIDCredential".into())); + + Ok(()) + } + + #[test] + fn test_input_descriptor_multi_credential_types_array() -> Result<()> { + let definition: PresentationDefinition = serde_json::from_str(include_str!( + "../../tests/presentation-definition/multi-credential-array.json" + ))?; + + let credentials = definition.credential_types_hint(); + + assert!(credentials.contains(&"IdentityCredential".into())); + assert!(credentials.contains(&"EducationalCredential".into())); + + Ok(()) + } + + #[test] + fn test_input_descriptor_multi_input_credential_types_enum() -> Result<()> { + let definition: PresentationDefinition = serde_json::from_str(include_str!( + "../../tests/presentation-definition/multi-input-credential-enum.json" + ))?; + + let input_descriptor_id_1 = "identity_credential"; + let input_descriptor_id_2 = "educational_credential"; + let input_descriptor_id_3 = "professional_credential"; + + let requested_credentials = definition.requested_credential_types_map(); + + assert_eq!(requested_credentials.len(), 3); + + let request_1 = requested_credentials + .get(input_descriptor_id_1) + .expect("failed to find input descriptor id"); + + assert!(request_1 + .credential_type_hint() + .contains(&"PassportCredential".into())); + assert!(request_1 + .credential_type_hint() + .contains(&"DriversLicenseCredential".into())); + assert!(request_1 + .credential_type_hint() + .contains(&"NationalIDCredential".into())); + + let request_2 = requested_credentials + .get(input_descriptor_id_2) + .expect("failed to find input descriptor id"); + + assert!(request_2 + .credential_type_hint() + .contains(&"BachelorDegreeCredential".into())); + assert!(request_2 + .credential_type_hint() + .contains(&"MasterDegreeCredential".into())); + assert!(request_2 + .credential_type_hint() + .contains(&"DoctoralDegreeCredential".into())); + + let request_3 = requested_credentials + .get(input_descriptor_id_3) + .expect("failed to find input descriptor id"); + + assert!(request_3 + .credential_type_hint() + .contains(&"ProfessionalLicenseCredential".into())); + assert!(request_3 + .credential_type_hint() + .contains(&"CertificationCredential".into())); + + Ok(()) + } +} diff --git a/tests/presentation-definition/iso.org.18013.5.1.mdl.json b/tests/presentation-definition/iso.org.18013.5.1.mdl.json new file mode 100644 index 0000000..58f7f0a --- /dev/null +++ b/tests/presentation-definition/iso.org.18013.5.1.mdl.json @@ -0,0 +1,67 @@ +{ + "id": "mDL_presentation_request", + "input_descriptors": [ + { + "id": "mDL_credential", + "name": "Mobile Driving License", + "purpose": "To verify your driving privileges and age.", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "array", + "contains": { + "const": "iso.org.18013.5.1.mDL" + } + } + }, + { + "path": ["$.credentialSubject.family_name", "$.vc.credentialSubject.family_name"], + "purpose": "We need your family name for identification.", + "filter": { + "type": "string" + } + }, + { + "path": ["$.credentialSubject.given_name", "$.vc.credentialSubject.given_name"], + "purpose": "We need your given name for identification.", + "filter": { + "type": "string" + } + }, + { + "path": ["$.credentialSubject.birth_date", "$.vc.credentialSubject.birth_date"], + "purpose": "We need your birth date to verify your age.", + "filter": { + "type": "string", + "format": "date" + } + }, + { + "path": ["$.credentialSubject.driving_privileges", "$.vc.credentialSubject.driving_privileges"], + "purpose": "We need to verify your driving privileges.", + "filter": { + "type": "array" + } + }, + { + "path": ["$.credentialSubject.document_number", "$.vc.credentialSubject.document_number"], + "purpose": "We need your license number for record-keeping.", + "filter": { + "type": "string" + } + }, + { + "path": ["$.credentialSubject.expiry_date", "$.vc.credentialSubject.expiry_date"], + "purpose": "We need to check if your license is still valid.", + "filter": { + "type": "string", + "format": "date" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/presentation-definition/multi-credential-array.json b/tests/presentation-definition/multi-credential-array.json new file mode 100644 index 0000000..e6e33bd --- /dev/null +++ b/tests/presentation-definition/multi-credential-array.json @@ -0,0 +1,88 @@ +{ + "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "name": "Identity and Qualifications Verification", + "purpose": "We need to verify your identity and qualifications", + "format": { + "jwt_vp": { + "alg": ["EdDSA", "ES256K"] + }, + "jwt_vc": { + "alg": ["EdDSA", "ES256K"] + }, + "ldp_vc": { + "proof_type": ["Ed25519Signature2018", "EcdsaSecp256k1Signature2019"] + }, + "ldp_vp": { + "proof_type": ["Ed25519Signature2018", "EcdsaSecp256k1Signature2019"] + } + }, + "input_descriptors": [ + { + "id": "combined_credential", + "name": "Identity and Education Credential", + "purpose": "Please provide a credential that includes both identity and educational information", + "constraints": { + "fields": [ + { + "path": ["$.type", "$.vc.type"], + "filter": { + "type": "array", + "contains": { + "const": "IdentityCredential" + } + } + }, + { + "path": ["$.type", "$.vc.type"], + "filter": { + "type": "array", + "contains": { + "const": "EducationalCredential" + } + } + }, + { + "path": ["$.credentialSubject.firstName", "$.vc.credentialSubject.firstName"], + "purpose": "The credential must contain the holder's first name", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.lastName", "$.vc.credentialSubject.lastName"], + "purpose": "The credential must contain the holder's last name", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.dateOfBirth", "$.vc.credentialSubject.dateOfBirth"], + "purpose": "The credential must contain the holder's date of birth", + "filter": { + "type": "string", + "format": "date" + } + }, + { + "path": ["$.credentialSubject.degree.name", "$.vc.credentialSubject.degree.name"], + "purpose": "The credential must contain the name of an educational degree", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.degree.institution", "$.vc.credentialSubject.degree.institution"], + "purpose": "The credential must contain the name of the educational institution", + "filter": { + "type": "string", + "minLength": 1 + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/presentation-definition/multi-credential-pattern.json b/tests/presentation-definition/multi-credential-pattern.json new file mode 100644 index 0000000..a763223 --- /dev/null +++ b/tests/presentation-definition/multi-credential-pattern.json @@ -0,0 +1,70 @@ +{ + "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "name": "Identity Verification", + "purpose": "We need to verify your identity for account creation", + "format": { + "jwt_vp": { + "alg": ["EdDSA", "ES256K"] + }, + "jwt_vc": { + "alg": ["EdDSA", "ES256K"] + }, + "ldp_vc": { + "proof_type": ["Ed25519Signature2018", "EcdsaSecp256k1Signature2019"] + }, + "ldp_vp": { + "proof_type": ["Ed25519Signature2018", "EcdsaSecp256k1Signature2019"] + } + }, + "input_descriptors": [ + { + "id": "identity_credential", + "name": "Identity Document", + "purpose": "Please provide a government-issued identity document", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "pattern": "^(PassportCredential|DriversLicenseCredential|NationalIDCredential)$" + } + }, + { + "path": ["$.credentialSubject.firstName"], + "purpose": "The credential must contain the holder's first name", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.lastName"], + "purpose": "The credential must contain the holder's last name", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.dateOfBirth"], + "purpose": "The credential must contain the holder's date of birth", + "filter": { + "type": "string", + "format": "date" + } + }, + { + "path": ["$.issuanceDate"], + "purpose": "The credential must have been issued within the last 5 years", + "filter": { + "type": "string", + "format": "date", + "minimum": "2018-01-01" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/presentation-definition/multi-input-credential-enum.json b/tests/presentation-definition/multi-input-credential-enum.json new file mode 100644 index 0000000..ad4bb53 --- /dev/null +++ b/tests/presentation-definition/multi-input-credential-enum.json @@ -0,0 +1,155 @@ +{ + "id": "32f54163-7166-48f1-93d8-ff217bdb0653", + "name": "Comprehensive Verification", + "purpose": "We need to verify your identity, education, and professional qualifications", + "format": { + "jwt_vp": { + "alg": ["EdDSA", "ES256K"] + }, + "jwt_vc": { + "alg": ["EdDSA", "ES256K"] + }, + "ldp_vc": { + "proof_type": ["Ed25519Signature2018", "EcdsaSecp256k1Signature2019"] + }, + "ldp_vp": { + "proof_type": ["Ed25519Signature2018", "EcdsaSecp256k1Signature2019"] + } + }, + "input_descriptors": [ + { + "id": "identity_credential", + "name": "Government-Issued Identity", + "purpose": "Verify your legal identity", + "constraints": { + "fields": [ + { + "path": ["$.type", "$.vc.type"], + "filter": { + "type": "array", + "contains": { + "enum": ["PassportCredential", "DriversLicenseCredential", "NationalIDCredential"] + } + } + }, + { + "path": ["$.credentialSubject.firstName", "$.vc.credentialSubject.firstName"], + "purpose": "Your legal first name", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.lastName", "$.vc.credentialSubject.lastName"], + "purpose": "Your legal last name", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.dateOfBirth", "$.vc.credentialSubject.dateOfBirth"], + "purpose": "Your date of birth", + "filter": { + "type": "string", + "format": "date" + } + } + ] + } + }, + { + "id": "educational_credential", + "name": "Educational Qualification", + "purpose": "Verify your highest educational qualification", + "constraints": { + "fields": [ + { + "path": ["$.type", "$.vc.type"], + "filter": { + "type": "array", + "contains": { + "enum": ["BachelorDegreeCredential", "MasterDegreeCredential", "DoctoralDegreeCredential"] + } + } + }, + { + "path": ["$.credentialSubject.degreeName", "$.vc.credentialSubject.degreeName"], + "purpose": "The name of your degree", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.institution", "$.vc.credentialSubject.institution"], + "purpose": "The institution that granted your degree", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.graduationDate", "$.vc.credentialSubject.graduationDate"], + "purpose": "Your graduation date", + "filter": { + "type": "string", + "format": "date" + } + } + ] + } + }, + { + "id": "professional_credential", + "name": "Professional Certification", + "purpose": "Verify your professional qualifications", + "constraints": { + "fields": [ + { + "path": ["$.type", "$.vc.type"], + "filter": { + "type": "array", + "contains": { + "enum": ["ProfessionalLicenseCredential", "CertificationCredential"] + } + } + }, + { + "path": ["$.credentialSubject.certificationName", "$.vc.credentialSubject.certificationName"], + "purpose": "The name of your professional certification", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.issuingAuthority", "$.vc.credentialSubject.issuingAuthority"], + "purpose": "The authority that issued your certification", + "filter": { + "type": "string", + "minLength": 1 + } + }, + { + "path": ["$.credentialSubject.issueDate", "$.vc.credentialSubject.issueDate"], + "purpose": "The date your certification was issued", + "filter": { + "type": "string", + "format": "date" + } + }, + { + "path": ["$.credentialSubject.expirationDate", "$.vc.credentialSubject.expirationDate"], + "purpose": "The expiration date of your certification", + "filter": { + "type": "string", + "format": "date" + } + } + ] + } + } + ] +} \ No newline at end of file