Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing satisfiesPresentationDefinition #143

Merged
merged 6 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import com.networknt.schema.JsonSchema
import com.nfeld.jsonpathkt.JsonPath
import com.nfeld.jsonpathkt.extension.read
import com.nimbusds.jose.Payload
import com.nimbusds.jwt.JWTParser
import com.nimbusds.jwt.SignedJWT

Expand All @@ -31,75 +30,128 @@
}

/**
* Validates if a Verifiable Credential JWT satisfies a Presentation Definition.
* Validates a list of Verifiable Credentials (VCs) against a specified Presentation Definition.
*
* @param vcJwt The Verifiable Credential JWT as a string.
* @param presentationDefinition The Presentation Definition to validate against.
* @throws UnsupportedOperationException If the Presentation Definition's Submission Requirements
* feature is not implemented.
* This function ensures that the provided VCs meet the criteria defined in the Presentation Definition.
* It first checks for the presence of Submission Requirements in the definition and throws an exception if they exist,
* as this feature is not implemented. Then, it maps the input descriptors in the presentation definition to the
* corresponding VCs. If the number of mapped descriptors does not match the required count, an error is thrown.
*
* @param vcJwts List of VCs in JWT format to validate.
* @param presentationDefinition The Presentation Definition V2 object against which VCs are validated.
* @throws UnsupportedOperationException If Submission Requirements are present in the definition.
* @throws PresentationExchangeError If the number of input descriptors matched is less than required.
*/
public fun satisfiesPresentationDefinition(
vcJwt: String,
vcJwts: List<String>,
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved
presentationDefinition: PresentationDefinitionV2
) {
val vc = JWTParser.parse(vcJwt) as SignedJWT

if (!presentationDefinition.submissionRequirements.isNullOrEmpty()) {
throw UnsupportedOperationException(
"Presentation Definition's Submission Requirements feature is not implemented"
)
}

presentationDefinition.inputDescriptors
.filter { !it.constraints.fields.isNullOrEmpty() }
.forEach { inputDescriptorWithFields ->
validateInputDescriptorsWithFields(inputDescriptorWithFields, vc.payload)
val inputDescriptorToVcMap = mapInputDescriptorsToVCs(vcJwts, presentationDefinition)

if (inputDescriptorToVcMap.size != presentationDefinition.inputDescriptors.size) {
throw PresentationExchangeError(
"Missing input descriptors: The presentation definition requires " +
"${presentationDefinition.inputDescriptors.size} descriptors, but only " +
"${inputDescriptorToVcMap.size} were found. Check and provide the missing descriptors."
)
Comment on lines +60 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be incredibly useful if the library would tell the user what input descriptors were missing. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're addressing later, then disregard this comment!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup addressing in the next PR

}
}

private fun mapInputDescriptorsToVCs(
vcJwtList: List<String>,
presentationDefinition: PresentationDefinitionV2
): Map<InputDescriptorV2, List<String>> {
val map = mutableMapOf<InputDescriptorV2, MutableList<String>>()

presentationDefinition.inputDescriptors.forEach { inputDescriptor ->
val satisfyingVCs = mutableListOf<String>()

vcJwtList.forEach { vcJwt ->
if (vcSatisfiesInputDescriptor(vcJwt, inputDescriptor)) {
satisfyingVCs.add(vcJwt)
}
}

if (satisfyingVCs.size > 0) {
map[inputDescriptor] = satisfyingVCs
}
}

return map
}
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved

/**
* Validates the input descriptors with associated fields in a Verifiable Credential.
* Evaluates if a Verifiable Credential (VC) satisfies the criteria defined in an Input Descriptor.
*
* Parses a Verifiable Credential (VC) from JWT format and verifies if it satisfies the Input Descriptor's criteria.
* This function evaluates each required field (where 'optional' is not true) in the descriptor against the VC's JSON payload.
* It extracts data from the VC payload using JSON paths defined in each field and checks compliance with any defined schema.
* Returns false if any required field is missing or fails schema validation, indicating non-compliance with the Input Descriptor.
* Otherwise, it returns true, signifying that the VC meets all criteria.
*
* @param inputDescriptorWithFields The Input Descriptor with associated fields.
* @param vcPayload The payload of the Verifiable Credential.
* @param vcJwt The JWT string representing the Verifiable Credential.
* @param inputDescriptor An instance of InputDescriptorV2 defining the criteria to be satisfied by the VC.
* @return Boolean indicating whether the VC satisfies the criteria of the Input Descriptor.
* @throws PresentationExchangeError Any errors during processing
*/
private fun validateInputDescriptorsWithFields(
inputDescriptorWithFields: InputDescriptorV2,
vcPayload: Payload
) {
val requiredFields = inputDescriptorWithFields.constraints.fields!!.filter { it.optional != true }
private fun vcSatisfiesInputDescriptor(
vcJwt: String,
inputDescriptor: InputDescriptorV2
): Boolean {
val vc = JWTParser.parse(vcJwt) as SignedJWT

requiredFields.forEach { field ->
val vcPayloadJson = JsonPath.parse(vcPayload.toString())
?: throw PresentationExchangeError("Failed to parse VC $vcPayload as JsonNode")
val vcPayloadJson = JsonPath.parse(vc.payload.toString())
?: throw PresentationExchangeError("Failed to parse VC payload as JSON.")

Check warning on line 110 in credentials/src/main/kotlin/web5/sdk/credentials/PresentationExchange.kt

View check run for this annotation

Codecov / codecov/patch

credentials/src/main/kotlin/web5/sdk/credentials/PresentationExchange.kt#L110

Added line #L110 was not covered by tests

val matchedFields = field.path.mapNotNull { path -> vcPayloadJson.read<JsonNode>(path) }
if (matchedFields.isEmpty()) {
throw PresentationExchangeError("Could not find matching field for path: ${field.path.joinToString()}")
}
// If the Input Descriptor has constraints and fields defined, evaluate them.
inputDescriptor.constraints?.fields?.let { fields ->
val requiredFields = fields.filter { field -> field.optional != true }
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved

for (field in requiredFields) {
val matchedFields = field.path.mapNotNull { path -> vcPayloadJson.read<JsonNode>(path) }
if (matchedFields.isEmpty()) {
// If no matching fields are found for a required field, the VC does not satisfy this Input Descriptor.
return false
}

when {
field.filterSchema != null -> {
matchedFields.any { fieldValue ->
when {
// When the field is an array, JSON schema is applied to each array item.
fieldValue.isArray -> {
if (fieldValue.none { valueSatisfiesFieldFilterSchema(it, field.filterSchema!!) })
throw PresentationExchangeError("Validating $fieldValue against ${field.filterSchema} failed")
true
}

// Otherwise, JSON schema is applied to the entire value.
else -> {
valueSatisfiesFieldFilterSchema(fieldValue, field.filterSchema!!)
}
}
// If there is a filter schema, process it
if (field.filterSchema != null) {
val satisfiesSchema = evaluateMatchedFields(matchedFields, field.filterSchema!!)
if (!satisfiesSchema) {
// If the field value does not satisfy the schema, the VC does not satisfy this Input Descriptor.
return false
}
}
}
}

// If the VC passes all the checks, it satisfies the criteria of the Input Descriptor.
return true
}

else -> return
/**
* Checks if any JsonNode in 'matchedFields' satisfies the 'schema'.
* Iterates through nodes: if a node or any element in a node array meets the schema, returns true; otherwise false.
*
* @param matchedFields List of JsonNodes to validate.
* @param schema JsonSchema to validate against.
* @return True if any field satisfies the schema, false if none do.
*/
private fun evaluateMatchedFields(matchedFields: List<JsonNode>, schema: JsonSchema): Boolean {
for (fieldValue in matchedFields) {
if (fieldValue.isArray() && fieldValue.any { valueSatisfiesFieldFilterSchema(it, schema) }) {
return true
} else if (!fieldValue.isArray() && valueSatisfiesFieldFilterSchema(fieldValue, schema)) {
return true
}
}
return false
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import web5.sdk.dids.methods.key.DidKey
import java.io.File
import kotlin.test.Test

data class DateOfBirth(val dateOfBirth: String)
data class Address(val address: String)
data class DateOfBirthSSN(val dateOfBirth: String, val ssn: String)
class PresentationExchangeTest {
private val keyManager = InMemoryKeyManager()
private val issuerDid = DidKey.create(keyManager)
Expand All @@ -38,7 +41,7 @@ class PresentationExchangeTest {
PresentationDefinitionV2::class.java
)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(sanctionsVcJwt, pd) }
assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(sanctionsVcJwt), pd) }
}

@Test
Expand All @@ -55,7 +58,7 @@ class PresentationExchangeTest {
)
val vcJwt = vc.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(vcJwt, pd) }
assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd) }
}

@Test
Expand All @@ -72,11 +75,11 @@ class PresentationExchangeTest {
)
val vcJwt = vc.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(vcJwt, pd) }
assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd) }
}

@Test
fun `does not throw when VC satisfies PD with field constraint`() {
fun `does not throw when VC satisfies PD with no filter field constraint`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_path_no_filter.json"),
PresentationDefinitionV2::class.java
Expand All @@ -89,11 +92,119 @@ class PresentationExchangeTest {
)
val vcJwt = vc.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(vcJwt, pd) }
assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd) }
}

@Test
fun `throws when VC does not satisfy requirements`() {
fun `does not throw when VC satisfies PD with no filter dob filed constraint`() {
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_path_no_filter_dob.json"),
PresentationDefinitionV2::class.java
)

val vc = VerifiableCredential.create(
type = "DateOfBirthVc",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = DateOfBirth(dateOfBirth = "1/1/1111")
)

val vcJwt = vc.sign(issuerDid)
assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd) }
}

@Test
fun `does not throw when VC satisfies PD with no filter dob filed constraint and extra VC`() {
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_path_no_filter_dob.json"),
PresentationDefinitionV2::class.java
)

val vc1 = VerifiableCredential.create(
type = "DateOfBirth",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = DateOfBirth(dateOfBirth = "Data1")
)
val vcJwt1 = vc1.sign(issuerDid)

val vc2 = VerifiableCredential.create(
type = "Address",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = Address("abc street 123")
)
val vcJwt2 = vc2.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt2, vcJwt1), pd) }
}

@Test
fun `does not throw when one VC satisfies both input descriptors PD`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_filter_array_multiple_input_descriptors.json"),
PresentationDefinitionV2::class.java
)

val vc1 = VerifiableCredential.create(
type = "DateOfBirthSSN",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = DateOfBirthSSN(dateOfBirth = "1999-01-01", ssn = "456-123-123")
)
val vcJwt1 = vc1.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt1), pd) }
}

@Test
fun `does not throw when one VC satisfies both input descriptors PD mixed filter`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_mixed_multiple_input_descriptors.json"),
PresentationDefinitionV2::class.java
)

val vc1 = VerifiableCredential.create(
type = "DateOfBirthSSN",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = DateOfBirthSSN(dateOfBirth = "1999-01-01", ssn = "456-123-123")
)
val vcJwt1 = vc1.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt1), pd) }
}

@Test
fun `does not throw when a valid presentation submission has two vc`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_path_no_filter_multiple_input_descriptors.json"),
PresentationDefinitionV2::class.java
)

val vc1 = VerifiableCredential.create(
type = "DateOfBirthVc",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = DateOfBirth(dateOfBirth = "1/1/1111")
)

val vcJwt1 = vc1.sign(issuerDid)

val vc2 = VerifiableCredential.create(
type = "Address",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = Address(address = "123 abc street")
)

val vcJwt2 = vc2.sign(issuerDid)

assertDoesNotThrow { PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt2, vcJwt1), pd) }
}

@Test
fun `throws when VC does not satisfy sanctions requirements`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_sanctions.json"),
PresentationDefinitionV2::class.java
Expand All @@ -107,8 +218,47 @@ class PresentationExchangeTest {
val vcJwt = vc.sign(issuerDid)

assertFailure {
PresentationExchange.satisfiesPresentationDefinition(vcJwt, pd)
}.messageContains("Validating [\"VerifiableCredential\",\"StreetCred\"]")
PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd)
}.messageContains("Missing input descriptors: The presentation definition requires")
}


@Test
fun `throws when VC does not satisfy no filter dob requirements`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_path_no_filter_dob.json"),
PresentationDefinitionV2::class.java
)
val vc = VerifiableCredential.create(
type = "StreetCred",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = StreetCredibility(localRespect = "high", legit = true)
)
val vcJwt = vc.sign(issuerDid)

assertFailure {
PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd)
}.messageContains("Missing input descriptors: The presentation definition requires")
}

@Test
fun `throws when VC does not satisfy filter streetCred requirements`() {
val pd = jsonMapper.readValue(
readPd("src/test/resources/pd_filter_array.json"),
PresentationDefinitionV2::class.java
)
val vc = VerifiableCredential.create(
type = "DateOfBirth",
issuer = issuerDid.uri,
subject = holderDid.uri,
data = DateOfBirth(dateOfBirth = "01-02-03")
)
val vcJwt = vc.sign(issuerDid)

assertFailure {
PresentationExchange.satisfiesPresentationDefinition(listOf(vcJwt), pd)
}.messageContains("Missing input descriptors: The presentation definition requires")
}
}
}
Loading