Skip to content

Commit

Permalink
PEX: Resolve values mapped by Input Descriptor constraint fields (#2667)
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul authored Dec 11, 2023
1 parent ac82d9b commit defad02
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 30 deletions.
64 changes: 50 additions & 14 deletions vcr/pe/presentation_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ func (presentationDefinition PresentationDefinition) Match(vcs []vc.VerifiableCr
return selectedVCs, descriptorMaps, nil
}

// ResolveConstraintsFields returns a map where each of the InputDescriptor constraints field is mapped,
// to the corresponding value from the Verifiable Credentials that map to the InputDescriptor.
// The credentialMap is a map with the InputDescriptor.Id as key and the VerifiableCredential as value.
// Constraints that contain no ID are ignored.
func (presentationDefinition PresentationDefinition) ResolveConstraintsFields(credentialMap map[string]vc.VerifiableCredential) (map[string]interface{}, error) {
result := make(map[string]interface{})
for inputDescriptorID, cred := range credentialMap {
// Find the input descriptor
var inputDescriptor InputDescriptor
for _, curr := range presentationDefinition.InputDescriptors {
if curr.Id == inputDescriptorID {
inputDescriptor = *curr
break
}
}
if inputDescriptor.Constraints == nil {
continue
}
_, values, err := matchConstraint(inputDescriptor.Constraints, cred)
if err != nil {
return nil, fmt.Errorf("failed to match constraint for input descriptor '%s' and credential '%s': %w", inputDescriptorID, cred.ID, err)
}
for key, value := range values {
result[key] = value
}
}
return result, nil
}

func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.VerifiableCredential) ([]Candidate, error) {
var candidates []Candidate

Expand Down Expand Up @@ -275,7 +304,8 @@ func matchCredential(descriptor InputDescriptor, credential vc.VerifiableCredent
// for each constraint in descriptor.constraints:
// a vc must match the constraint
if descriptor.Constraints != nil {
return matchConstraint(descriptor.Constraints, credential)
matches, _, err := matchConstraint(descriptor.Constraints, credential)
return matches, err
}
return true, nil
}
Expand All @@ -284,7 +314,8 @@ func matchCredential(descriptor InputDescriptor, credential vc.VerifiableCredent
// All Fields need to match according to the Field rules.
// IsHolder, SameSubject, SubjectIsIssuer, Statuses are not supported for now.
// LimitDisclosure is not supported for now.
func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential) (bool, error) {
// If the constraint matches, it returns true and a map containing constraint field IDs and matched values.
func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential) (bool, map[string]interface{}, error) {
// jsonpath works on interfaces, so convert the VC to an interface
var credentialAsMap map[string]interface{}
var err error
Expand All @@ -298,60 +329,65 @@ func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential
credentialAsMap, err = remarshalToMap(credential)
}
if err != nil {
return false, err
return false, nil, err
}

// for each field in constraint.fields:
// a vc must match the field
values := make(map[string]interface{})
for _, field := range constraint.Fields {
match, err := matchField(field, credentialAsMap)
match, value, err := matchField(field, credentialAsMap)
if err != nil {
return false, err
return false, nil, err
}
if !match {
return false, nil
return false, nil, nil
}
if field.Id != nil {
values[*field.Id] = value
}
}
return true, nil
return true, values, nil
}

// matchField matches the field against the VC.
// If the field matches, it returns true and the matched value. The matched value can be nil if the field is optional.
// All fields need to match unless optional is set to true and no values are found for all the paths.
func matchField(field Field, credential map[string]interface{}) (bool, error) {
func matchField(field Field, credential map[string]interface{}) (bool, interface{}, error) {
// for each path in field.paths:
// a vc must match one of the path
var optionalInvalid int
for _, path := range field.Path {
// if path is not found continue
value, err := getValueAtPath(path, credential)
if err != nil {
return false, err
return false, nil, err
}
if value == nil {
continue
}

if field.Filter == nil {
return true, nil
return true, value, nil
}

// if filter at path matches return true
match, err := matchFilter(*field.Filter, value)
if err != nil {
return false, err
return false, nil, err
}
if match {
return true, nil
return true, value, nil
}
// if filter at path does not match continue and set optionalInvalid
optionalInvalid++
}
// no matches, check optional. Optional is only valid if all paths returned no results
// not if a filter did not match
if field.Optional != nil && *field.Optional && optionalInvalid == 0 {
return true, nil
return true, nil, nil
}
return false, nil
return false, nil, nil
}

// getValueAtPath uses the JSON path expression to get the value from the VC
Expand Down
104 changes: 88 additions & 16 deletions vcr/pe/presentation_definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,39 +443,54 @@ func Test_matchConstraint(t *testing.T) {
testCredential := vc.VerifiableCredential{}
_ = json.Unmarshal([]byte(testCredentialString), &testCredential)

credSubjectFieldID := "credential_subject_field"
typeVal := "VerifiableCredential"
f1True := Field{Path: []string{"$.credentialSubject.field"}}
f1True := Field{Id: &credSubjectFieldID, Path: []string{"$.credentialSubject.field"}}
f1TrueWithoutID := Field{Path: []string{"$.credentialSubject.field"}}
f2True := Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &typeVal}}
f3False := Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &typeVal}}
fieldMap := map[string]interface{}{credSubjectFieldID: "value"}

t.Run("single constraint match", func(t *testing.T) {
match, err := matchConstraint(&Constraints{Fields: []Field{f1True}}, testCredential)
match, value, err := matchConstraint(&Constraints{Fields: []Field{f1True}}, testCredential)

require.NoError(t, err)
assert.Equal(t, fieldMap, value)
assert.True(t, match)
})
t.Run("field match without ID is not included in values map", func(t *testing.T) {
match, values, err := matchConstraint(&Constraints{Fields: []Field{f1TrueWithoutID}}, testCredential)

require.NoError(t, err)
assert.Empty(t, values)
assert.True(t, match)
})
t.Run("single constraint mismatch", func(t *testing.T) {
match, err := matchConstraint(&Constraints{Fields: []Field{f3False}}, testCredential)
match, values, err := matchConstraint(&Constraints{Fields: []Field{f3False}}, testCredential)

require.NoError(t, err)
assert.Nil(t, values)
assert.False(t, match)
})
t.Run("multi constraint match", func(t *testing.T) {
match, err := matchConstraint(&Constraints{Fields: []Field{f1True, f2True}}, testCredential)
match, values, err := matchConstraint(&Constraints{Fields: []Field{f1True, f2True}}, testCredential)

require.NoError(t, err)
assert.Equal(t, fieldMap, values)
assert.True(t, match)
})
t.Run("multi constraint, single mismatch", func(t *testing.T) {
match, err := matchConstraint(&Constraints{Fields: []Field{f1True, f3False}}, testCredential)
match, values, err := matchConstraint(&Constraints{Fields: []Field{f1True, f3False}}, testCredential)

require.NoError(t, err)
assert.Nil(t, values)
assert.False(t, match)
})
t.Run("error", func(t *testing.T) {
match, err := matchConstraint(&Constraints{Fields: []Field{{Path: []string{"$$"}}}}, testCredential)
match, values, err := matchConstraint(&Constraints{Fields: []Field{{Path: []string{"$$"}}}}, testCredential)

require.Error(t, err)
assert.Nil(t, values)
assert.False(t, match)
})
}
Expand All @@ -486,50 +501,57 @@ func Test_matchField(t *testing.T) {
testCredentialMap, _ := remarshalToMap(testCredential)

t.Run("single path match", func(t *testing.T) {
match, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, "value", value)
assert.True(t, match)
})
t.Run("multi path match", func(t *testing.T) {
match, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, "value", value)
assert.True(t, match)
})
t.Run("no match", func(t *testing.T) {
match, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap)

require.NoError(t, err)
assert.Nil(t, value)
assert.False(t, match)
})
t.Run("no match, but optional", func(t *testing.T) {
trueVal := true
match, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap)

require.NoError(t, err)
assert.Nil(t, value)
assert.True(t, match)
})
t.Run("invalid match and optional", func(t *testing.T) {
trueVal := true
stringVal := "bar"
match, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Nil(t, value)
assert.False(t, match)
})
t.Run("valid match with Filter", func(t *testing.T) {
stringVal := "value"
match, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, stringVal, value)
assert.True(t, match)
})
t.Run("match on type", func(t *testing.T) {
stringVal := "VerifiableCredential"
match, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, stringVal, value)
assert.True(t, match)
})
t.Run("match on type array", func(t *testing.T) {
Expand All @@ -542,24 +564,27 @@ func Test_matchField(t *testing.T) {
}`
_ = json.Unmarshal([]byte(testCredentialString), &testCredentialMap)
stringVal := "VerifiableCredential"
match, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, []interface{}{"VerifiableCredential"}, value)
assert.True(t, match)
})

t.Run("errors", func(t *testing.T) {
t.Run("invalid path", func(t *testing.T) {
match, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap)

require.Error(t, err)
assert.Nil(t, value)
assert.False(t, match)
})
t.Run("invalid pattern", func(t *testing.T) {
pattern := "["
match, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap)
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap)

require.Error(t, err)
assert.Nil(t, value)
assert.False(t, match)
})
})
Expand Down Expand Up @@ -645,6 +670,53 @@ func Test_matchFilter(t *testing.T) {
})
}

func TestPresentationDefinition_ResolveConstraintsFields(t *testing.T) {
jwtCredential := credential.JWTNutsOrganizationCredential(t)
jsonldCredential := credential.JWTNutsOrganizationCredential(t)
definition := definitions().JSONLDorJWT
t.Run("match JWT", func(t *testing.T) {
credentialMap := map[string]vc.VerifiableCredential{
"organization_credential": jwtCredential,
}

fieldValues, _ := definition.ResolveConstraintsFields(credentialMap)

require.Len(t, fieldValues, 2)
assert.Equal(t, "IJbergen", fieldValues["credentialsubject_organization_city"])
assert.Equal(t, "care", fieldValues["credentialsubject_organization_name"])
})
t.Run("match JSON-LD", func(t *testing.T) {
credentialMap := map[string]vc.VerifiableCredential{
"organization_credential": jsonldCredential,
}

fieldValues, _ := definition.ResolveConstraintsFields(credentialMap)

require.Len(t, fieldValues, 2)
assert.Equal(t, "IJbergen", fieldValues["credentialsubject_organization_city"])
assert.Equal(t, "care", fieldValues["credentialsubject_organization_name"])
})
t.Run("input descriptor without constraints", func(t *testing.T) {
format := PresentationDefinitionClaimFormatDesignations(map[string]map[string][]string{"jwt_vc": {"alg": {"ES256"}}})
definition := PresentationDefinition{
InputDescriptors: []*InputDescriptor{
{
Id: "any_credential",
Format: &format,
},
},
}
credentialMap := map[string]vc.VerifiableCredential{
"any_credential": jwtCredential,
}

fieldValues, err := definition.ResolveConstraintsFields(credentialMap)

require.NoError(t, err)
assert.Empty(t, fieldValues)
})
}

func credentialToJSONLD(credential vc.VerifiableCredential) vc.VerifiableCredential {
bytes, err := credential.MarshalJSON()
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions vcr/pe/test/pd_jsonld_jwt.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
"id": "Definition requesting NutsOrganizationCredential",
"input_descriptors": [
{
"id": "organization_credential",
"constraints": {
"fields": [
{
"id": "credentialsubject_organization_city",
"path": [
"$.credentialSubject.organization.city",
"$.credentialSubject[0].organization.city"
Expand All @@ -15,6 +17,7 @@
}
},
{
"id": "credentialsubject_organization_name",
"path": [
"$.credentialSubject.organization.name",
"$.credentialSubject[0].organization.name"
Expand Down

0 comments on commit defad02

Please sign in to comment.