From 37283bf20473afd439e85e1eeb81e234d3516643 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Mon, 31 Jul 2023 11:54:59 +0200 Subject: [PATCH] Add basic PresentationDefinition to VC matching (#2381) --- go.mod | 4 + go.sum | 10 + vcr/pe/fields.go | 42 +++++ vcr/pe/matcher.go | 292 +++++++++++++++++++++++++++++ vcr/pe/matcher_test.go | 401 ++++++++++++++++++++++++++++++++++++++++ vcr/pe/schema/README.md | 11 ++ vcr/pe/schema/go.mod | 7 + vcr/pe/schema/go.sum | 16 ++ vcr/pe/schema/main.go | 95 ++++++++++ vcr/pe/types.go | 102 ++++++++++ 10 files changed, 980 insertions(+) create mode 100644 vcr/pe/fields.go create mode 100644 vcr/pe/matcher.go create mode 100644 vcr/pe/matcher_test.go create mode 100644 vcr/pe/schema/README.md create mode 100644 vcr/pe/schema/go.mod create mode 100644 vcr/pe/schema/go.sum create mode 100644 vcr/pe/schema/main.go create mode 100644 vcr/pe/types.go diff --git a/go.mod b/go.mod index 48fb33488a..2a76881af9 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module github.com/nuts-foundation/nuts-node go 1.20 require ( + github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 github.com/alicebob/miniredis/v2 v2.30.4 github.com/avast/retry-go/v4 v4.3.4 github.com/cbroglie/mustache v1.4.0 github.com/chromedp/chromedp v0.9.1 github.com/deepmap/oapi-codegen v1.13.0 + github.com/dlclark/regexp2 v1.10.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/goodsign/monday v1.0.1 github.com/google/uuid v1.3.0 @@ -48,6 +50,7 @@ require ( ) require ( + github.com/PaesslerAG/gval v1.2.2 // indirect github.com/alexandrevicenzi/go-sse v1.6.0 // indirect github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect @@ -149,6 +152,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/templexxx/cpu v0.0.9 // indirect diff --git a/go.sum b/go.sum index c9a954c566..5f75ea41be 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,11 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E= +github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 h1:XIsQOSBJi/9Bexr+rjUpuYi0IkQ+YqNKKlE7Yt/sw9Q= +github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -106,6 +111,8 @@ github.com/dgraph-io/badger/v4 v4.1.0 h1:E38jc0f+RATYrycSUf9LMv/t47XAy+3CApyYSq4 github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= @@ -235,6 +242,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -533,6 +541,8 @@ github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIH github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs= github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50 h1:vgWWQM2SnMoO9BiUZ2WFAYuYF6U0jNss9Vn/PZoi+tU= github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50/go.mod h1:W/QHK9G0i5yrmHvej5+hhoFMXTSZIWHGQRcpbGgqV9s= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= diff --git a/vcr/pe/fields.go b/vcr/pe/fields.go new file mode 100644 index 0000000000..1e59ea6f47 --- /dev/null +++ b/vcr/pe/fields.go @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +// Field describes a field in a presentation submission, predicate feature is not implemented +type Field struct { + Id *string `json:"id,omitempty"` + Optional *bool `json:"optional,omitempty"` + Path []string `json:"path"` + Purpose *string `json:"purpose,omitempty"` + Name *string `json:"name,omitempty"` + IntentToRetain *bool `json:"intent_to_retain,omitempty"` + Filter *Filter `json:"filter,omitempty"` +} + +// Filter is a JSON Schema (without nesting) +type Filter struct { + // Type is the type of field: string, number, boolean, array, object + Type string `json:"type"` + // Const is a constant value to match, currently only strings are supported + Const *string `json:"const,omitempty"` + // Enum is a list of values to match + Enum []string `json:"enum,omitempty"` + // Pattern is a pattern to match according to ECMA-262, section 21.2.1 + Pattern *string `json:"pattern,omitempty"` +} diff --git a/vcr/pe/matcher.go b/vcr/pe/matcher.go new file mode 100644 index 0000000000..d11d4ee28c --- /dev/null +++ b/vcr/pe/matcher.go @@ -0,0 +1,292 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/PaesslerAG/jsonpath" + "github.com/dlclark/regexp2" + "github.com/google/uuid" + "github.com/nuts-foundation/go-did/vc" + "strings" +) + +// ErrUnsupportedFilter is returned when a filter uses unsupported features. +var ErrUnsupportedFilter = errors.New("unsupported filter") + +// Match matches the VCs against the presentation definition. +// It implements ยง5 of the Presentation Exchange specification (v2.x.x pre-Draft, 2023-07-29) (https://identity.foundation/presentation-exchange/#presentation-definition) +// It only supports the following: +// - ldp_vc format +// - pattern, const and enum only on string fields +// - number, boolean, array and string JSON schema types +// It doesn't do the credential search, this should be done before calling this function. +// The PresentationDefinition.Format should be altered/set if an envelope defines the supported format before calling. +// The resulting PresentationSubmission has paths that are relative to the matching VCs. +// The PresentationSubmission needs to be altered so the paths use "path_nested"s that are relative to the created VP. +// ErrUnsupportedFilter is returned when a filter uses unsupported features. +// Other errors can be returned for faulty JSON paths or regex patterns. +func (presentationDefinition PresentationDefinition) Match(vcs []vc.VerifiableCredential) (PresentationSubmission, []vc.VerifiableCredential, error) { + // for each VC in vcs: + // for each descriptor in presentation_definition.descriptors: + // for each constraint in descriptor.constraints: + // for each field in constraint.fields: + // a vc must match the field + presentationSubmission := PresentationSubmission{ + Id: uuid.New().String(), + DefinitionId: presentationDefinition.Id, + } + var matchingCredentials []vc.VerifiableCredential + var index int + for _, inputDescriptor := range presentationDefinition.InputDescriptors { + var mapping *InputDescriptorMappingObject + var err error + for _, credential := range vcs { + mapping, err = matchDescriptor(*inputDescriptor, credential) + if err != nil { + return PresentationSubmission{}, nil, err + } + if mapping != nil && matchFormat(presentationDefinition.Format, credential) { + mapping.Path = fmt.Sprintf("$.verifiableCredential[%d]", index) + presentationSubmission.DescriptorMap = append(presentationSubmission.DescriptorMap, *mapping) + matchingCredentials = append(matchingCredentials, credential) + index++ + break + } + } + if mapping == nil { + return PresentationSubmission{}, []vc.VerifiableCredential{}, nil + } + } + + return presentationSubmission, matchingCredentials, nil +} + +// matchFormat checks if the credential matches the Format from the presentationDefinition. +// if one of format['ldp_vc'] or format['jwt_vc'] is present, the VC must match that format. +// If the VC is of the required format, the alg or proofType must also match. +// vp formats are ignored. +// This might not be fully interoperable, but the spec at https://identity.foundation/presentation-exchange/#presentation-definition is not clear on this. +func matchFormat(format *PresentationDefinitionClaimFormatDesignations, credential vc.VerifiableCredential) bool { + if format == nil { + return true + } + + asMap := map[string]map[string][]string(*format) + // we're only interested in the jwt_vc and ldp_vc formats + if asMap["jwt_vc"] == nil && asMap["ldp_vc"] == nil { + return true + } + + // only ldp_vc supported for now + if entry := asMap["ldp_vc"]; entry != nil { + if proofTypes := entry["proof_type"]; proofTypes != nil { + for _, proofType := range proofTypes { + if matchProofType(proofType, credential) { + return true + } + } + } + } + + return false +} + +func matchProofType(proofType string, credential vc.VerifiableCredential) bool { + proofs, _ := credential.Proofs() + for _, p := range proofs { + if string(p.Type) == proofType { + return true + } + } + return false +} + +func matchDescriptor(descriptor InputDescriptor, credential vc.VerifiableCredential) (*InputDescriptorMappingObject, error) { + match, err := matchCredential(descriptor, credential) + if err != nil { + return nil, err + } + if !match { + return nil, nil + } + + return &InputDescriptorMappingObject{ + Id: descriptor.Id, + Format: "ldp_vc", // todo: hardcoded for now, must be derived from the VC, but we don't support other VC types yet + }, nil +} + +func matchCredential(descriptor InputDescriptor, credential vc.VerifiableCredential) (bool, error) { + // for each constraint in descriptor.constraints: + // a vc must match the constraint + if descriptor.Constraints != nil { + return matchConstraint(descriptor.Constraints, credential) + } + return true, nil +} + +// matchConstraint matches the constraint against the VC. +// 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) { + // for each field in constraint.fields: + // a vc must match the field + for _, field := range constraint.Fields { + match, err := matchField(field, credential) + if err != nil { + return false, err + } + if !match { + return false, nil + } + } + return true, nil +} + +// matchField matches the field against the VC. +// 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 vc.VerifiableCredential) (bool, error) { + // jsonpath works on interfaces, so convert the VC to an interface + asJSON, _ := json.Marshal(credential) + var asInterface interface{} + _ = json.Unmarshal(asJSON, &asInterface) + + // 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, asInterface) + if err != nil { + return false, err + } + if value == nil { + continue + } + + if field.Filter == nil { + return true, nil + } + + // if filter at path matches return true + match, err := matchFilter(*field.Filter, value) + if err != nil { + return false, err + } + if match { + return true, 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 false, nil +} + +// getValueAtPath uses the JSON path expression to get the value from the VC +func getValueAtPath(path string, vcAsInterface interface{}) (interface{}, error) { + value, err := jsonpath.Get(path, vcAsInterface) + // jsonpath.Get returns some errors if the path is not found, or it has a different type as expected + if err != nil && (strings.HasPrefix(err.Error(), "unknown key") || strings.HasPrefix(err.Error(), "unsupported value type")) { + return nil, nil + } + return value, err +} + +// matchFilter matches the value against the filter. +// A filter is a JSON Schema descriptor (https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-structural) +// Supported schema types: string, number, boolean, array, enum. +// Supported schema properties: const, enum, pattern. These only work for strings. +// Supported go value types: string, float64, int, bool and array. +// 'null' values are also not supported. +// It returns an error on unsupported features or when the regex pattern fails. +func matchFilter(filter Filter, value interface{}) (bool, error) { + // first we check if it's an enum, so we can recursively call matchFilter for each value + if filter.Enum != nil { + for _, enum := range filter.Enum { + f := Filter{ + Type: "string", + Const: &enum, + } + match, _ := matchFilter(f, value) + if match { + return true, nil + } + } + return false, nil + } + + switch value.(type) { + case string: + if filter.Type != "string" { + return false, nil + } + case float64: + if filter.Type != "number" { + return false, nil + } + case int: + if filter.Type != "number" { + return false, nil + } + case bool: + if filter.Type != "boolean" { + return false, nil + } + case []interface{}: + values := value.([]interface{}) + for _, v := range values { + match, err := matchFilter(filter, v) + if err != nil { + return false, err + } + if match { + return true, nil + } + } + default: + // object not supported for now + return false, ErrUnsupportedFilter + } + + if filter.Const != nil { + if value != *filter.Const { + return false, nil + } + } + + if filter.Pattern != nil && filter.Type == "string" { + re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript) + if err != nil { + return false, err + } + return re.MatchString(value.(string)) + } + + // if we get here, no pattern, enum or const is requested just the type. + return true, nil +} diff --git a/vcr/pe/matcher_test.go b/vcr/pe/matcher_test.go new file mode 100644 index 0000000000..51fff6c36d --- /dev/null +++ b/vcr/pe/matcher_test.go @@ -0,0 +1,401 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "testing" +) + +const testPresentationDefinition = ` +{ + "id": "Definition requesting NutsOrganizationCredential", + "input_descriptors": [ + { + "id": "some random ID", + "name": "Organization matcher", + "purpose": "Finding any organization in CareTown starting with 'Care'", + "constraints": { + "fields": [ + { + "path": [ + "$.credentialSubject.organization.city" + ], + "filter": { + "type": "string", + "const": "IJbergen" + } + }, + { + "path": [ + "$.credentialSubject.organization.name" + ], + "filter": { + "type": "string", + "pattern": "care" + } + }, + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ], + "format": { + "jwt_vc": { + "alg": ["ES256K", "ES384"] + }, + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + } +} +` + +var testCredentialString = ` +{ + "type": "VerifiableCredential", + "credentialSubject": { + "field": "value" + } +}` + +func TestMatch(t *testing.T) { + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(testPresentationDefinition), &presentationDefinition) + verifiableCredential := vc.VerifiableCredential{} + vcJSON, _ := os.ReadFile("../test/vc.json") + _ = json.Unmarshal(vcJSON, &verifiableCredential) + + t.Run("Happy flow", func(t *testing.T) { + presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{verifiableCredential}) + + require.NoError(t, err) + assert.Len(t, vcs, 1) + require.Len(t, presentationSubmission.DescriptorMap, 1) + assert.Equal(t, "$.verifiableCredential[0]", presentationSubmission.DescriptorMap[0].Path) + }) + t.Run("Only second VC matches", func(t *testing.T) { + presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{{Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}}, verifiableCredential}) + + require.NoError(t, err) + assert.Len(t, vcs, 1) + require.Len(t, presentationSubmission.DescriptorMap, 1) + assert.Equal(t, "$.verifiableCredential[0]", presentationSubmission.DescriptorMap[0].Path) + }) +} + +func Test_matchFormat(t *testing.T) { + verifiableCredential := vc.VerifiableCredential{} + vcJSON, _ := os.ReadFile("../test/vc.json") + _ = json.Unmarshal(vcJSON, &verifiableCredential) + + t.Run("no format", func(t *testing.T) { + match := matchFormat(nil, vc.VerifiableCredential{}) + + assert.True(t, match) + }) + + t.Run("empty format", func(t *testing.T) { + match := matchFormat(&PresentationDefinitionClaimFormatDesignations{}, vc.VerifiableCredential{}) + + assert.True(t, match) + }) + + t.Run("format with jwt_vc always returns false", func(t *testing.T) { + asMap := map[string]map[string][]string{"jwt_vc": {"alg": {"ES256K", "ES384"}}} + asFormat := PresentationDefinitionClaimFormatDesignations(asMap) + match := matchFormat(&asFormat, vc.VerifiableCredential{}) + + assert.False(t, match) + }) + + t.Run("format with matching ldp_vc", func(t *testing.T) { + asMap := map[string]map[string][]string{"jwt_vc": {"alg": {"ES256K", "ES384"}}, "ldp_vc": {"proof_type": {"JsonWebSignature2020"}}} + asFormat := PresentationDefinitionClaimFormatDesignations(asMap) + match := matchFormat(&asFormat, verifiableCredential) + + assert.True(t, match) + }) + + t.Run("non-matching ldp_vc", func(t *testing.T) { + asMap := map[string]map[string][]string{"jwt_vc": {"alg": {"ES256K", "ES384"}}, "ldp_vc": {"proof_type": {"Ed25519Signature2018"}}} + asFormat := PresentationDefinitionClaimFormatDesignations(asMap) + match := matchFormat(&asFormat, verifiableCredential) + + assert.False(t, match) + }) + + t.Run("missing proof_type", func(t *testing.T) { + asMap := map[string]map[string][]string{"ldp_vc": {}} + asFormat := PresentationDefinitionClaimFormatDesignations(asMap) + match := matchFormat(&asFormat, verifiableCredential) + + assert.False(t, match) + }) +} + +func Test_matchDescriptor(t *testing.T) { + testCredential := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(testCredentialString), &testCredential) + t.Run("no match", func(t *testing.T) { + field := Field{Path: []string{"$.credentialSubject.foo"}} + + idmo, err := matchDescriptor(InputDescriptor{Constraints: &Constraints{Fields: []Field{field}}}, testCredential) + + require.NoError(t, err) + assert.Nil(t, idmo) + }) + t.Run("match", func(t *testing.T) { + field := Field{Path: []string{"$.credentialSubject.field"}} + + idmo, err := matchDescriptor(InputDescriptor{Constraints: &Constraints{Fields: []Field{field}}}, testCredential) + + require.NoError(t, err) + require.NotNil(t, idmo) + }) +} + +func Test_matchCredential(t *testing.T) { + t.Run("no constraints is a match", func(t *testing.T) { + match, err := matchCredential(InputDescriptor{}, vc.VerifiableCredential{}) + + require.NoError(t, err) + assert.True(t, match) + }) +} + +func Test_matchConstraint(t *testing.T) { + testCredential := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(testCredentialString), &testCredential) + + typeVal := "VerifiableCredential" + f1True := 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}} + + t.Run("single constraint match", func(t *testing.T) { + match, err := matchConstraint(&Constraints{Fields: []Field{f1True}}, testCredential) + + require.NoError(t, err) + assert.True(t, match) + }) + t.Run("single constraint mismatch", func(t *testing.T) { + match, err := matchConstraint(&Constraints{Fields: []Field{f3False}}, testCredential) + + require.NoError(t, err) + assert.False(t, match) + }) + t.Run("multi constraint match", func(t *testing.T) { + match, err := matchConstraint(&Constraints{Fields: []Field{f1True, f2True}}, testCredential) + + require.NoError(t, err) + assert.True(t, match) + }) + t.Run("multi constraint, single mismatch", func(t *testing.T) { + match, err := matchConstraint(&Constraints{Fields: []Field{f1True, f3False}}, testCredential) + + require.NoError(t, err) + assert.False(t, match) + }) + t.Run("error", func(t *testing.T) { + match, err := matchConstraint(&Constraints{Fields: []Field{{Path: []string{"$$"}}}}, testCredential) + + require.Error(t, err) + assert.False(t, match) + }) +} + +func Test_matchField(t *testing.T) { + testCredential := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(testCredentialString), &testCredential) + + t.Run("single path match", func(t *testing.T) { + match, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredential) + + require.NoError(t, err) + assert.True(t, match) + }) + t.Run("multi path match", func(t *testing.T) { + match, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredential) + + require.NoError(t, err) + assert.True(t, match) + }) + t.Run("no match", func(t *testing.T) { + match, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredential) + + require.NoError(t, err) + 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}, testCredential) + + require.NoError(t, err) + 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}}, testCredential) + + require.NoError(t, err) + 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}}, testCredential) + + require.NoError(t, err) + 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}}, testCredential) + + require.NoError(t, err) + assert.True(t, match) + }) + t.Run("match on type array", func(t *testing.T) { + testCredentialString = ` +{ + "type": ["VerifiableCredential"], + "credentialSubject": { + "field": "value" + } +}` + _ = json.Unmarshal([]byte(testCredentialString), &testCredential) + stringVal := "VerifiableCredential" + match, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredential) + + require.NoError(t, err) + 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{"$$"}}, testCredential) + + require.Error(t, err) + 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}}, testCredential) + + require.Error(t, err) + assert.False(t, match) + }) + }) +} + +func Test_matchFilter(t *testing.T) { + // values for pointer fields + stringValue := "test" + boolValue := true + intValue := 1 + floatValue := 1.0 + + t.Run("type filter", func(t *testing.T) { + fString := Filter{Type: "string"} + fNumber := Filter{Type: "number"} + fBoolean := Filter{Type: "boolean"} + type testCaseDef struct { + name string + filter Filter + value interface{} + want bool + } + testCases := []testCaseDef{ + {name: "string", filter: fString, value: stringValue, want: true}, + {name: "bool", filter: fBoolean, value: boolValue, want: true}, + {name: "number/float", filter: fNumber, value: floatValue, want: true}, + {name: "number/int", filter: fNumber, value: intValue, want: true}, + {name: "string array", filter: fString, value: []interface{}{stringValue}, want: true}, + {name: "bool array", filter: fBoolean, value: []interface{}{boolValue}, want: true}, + {name: "number/float array", filter: fNumber, value: []interface{}{floatValue}, want: true}, + {name: "number/int array", filter: fNumber, value: []interface{}{intValue}, want: true}, + {name: "string with bool", filter: fString, value: boolValue, want: false}, + {name: "string with int", filter: fString, value: intValue, want: false}, + {name: "bool with float", filter: fBoolean, value: floatValue, want: false}, + {name: "number with string", filter: fNumber, value: stringValue, want: false}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got, err := matchFilter(testCase.filter, testCase.value) + require.NoError(t, err) + assert.Equal(t, testCase.want, got) + }) + } + }) + + t.Run("string filter properties", func(t *testing.T) { + f1 := Filter{Type: "string", Const: &stringValue} + f2 := Filter{Type: "string", Enum: []string{stringValue}} + f3 := Filter{Type: "string", Pattern: &stringValue} + filters := []Filter{f1, f2, f3} + t.Run("ok", func(t *testing.T) { + for _, filter := range filters { + match, err := matchFilter(filter, stringValue) + require.NoError(t, err) + assert.True(t, match) + } + }) + t.Run("enum value not found", func(t *testing.T) { + match, err := matchFilter(f2, "foo") + require.NoError(t, err) + assert.False(t, match) + }) + }) + + t.Run("error cases", func(t *testing.T) { + t.Run("enum with wrong type", func(t *testing.T) { + f := Filter{Type: "object"} + match, err := matchFilter(f, struct{}{}) + assert.False(t, match) + assert.Equal(t, err, ErrUnsupportedFilter) + }) + t.Run("incorrect regex", func(t *testing.T) { + pattern := "[" + f := Filter{Type: "string", Pattern: &pattern} + match, err := matchFilter(f, stringValue) + assert.False(t, match) + assert.Error(t, err, "error parsing regexp: missing closing ]: `[`") + match, err = matchFilter(f, []interface{}{stringValue}) + assert.False(t, match) + assert.Error(t, err, "error parsing regexp: missing closing ]: `[`") + }) + }) +} diff --git a/vcr/pe/schema/README.md b/vcr/pe/schema/README.md new file mode 100644 index 0000000000..fac22a7041 --- /dev/null +++ b/vcr/pe/schema/README.md @@ -0,0 +1,11 @@ +# generate structs from JSON schema + +From this directory, run: + +```shell +go run . +``` + +It'll generate `generated.go` within the `pe` package. +The generated code is not really useful, but it could serve as a guide for the types that are expected by the API. +The output of `generated.go` is copied to `types.go` \ No newline at end of file diff --git a/vcr/pe/schema/go.mod b/vcr/pe/schema/go.mod new file mode 100644 index 0000000000..e62980ea0c --- /dev/null +++ b/vcr/pe/schema/go.mod @@ -0,0 +1,7 @@ +module github.com/nuts-foundation/nuts-node/vcr/pe/schema + +go 1.20 + +require ( + github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60 // indirect +) diff --git a/vcr/pe/schema/go.sum b/vcr/pe/schema/go.sum new file mode 100644 index 0000000000..1f994a18a1 --- /dev/null +++ b/vcr/pe/schema/go.sum @@ -0,0 +1,16 @@ +github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E= +github.com/PaesslerAG/gval v1.2.2/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= +github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 h1:XIsQOSBJi/9Bexr+rjUpuYi0IkQ+YqNKKlE7Yt/sw9Q= +github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= +github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60 h1:/rNdG6EuzjwcR1KRFpF+9qWmWh2xIcz84QOeMGr/2L8= +github.com/a-h/generate v0.0.0-20220105161013-96c14dfdfb60/go.mod h1:traiLYQ0YD7qUMCdjo6/jSaJRPHXniX4HVs+PhEhYpc= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/vcr/pe/schema/main.go b/vcr/pe/schema/main.go new file mode 100644 index 0000000000..2eb767ecd4 --- /dev/null +++ b/vcr/pe/schema/main.go @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "github.com/a-h/generate" + "io" + "net/http" + "net/url" + "os" + "time" +) + +const ( + claimFormatDescriptorLocation = "http://identity.foundation/claim-format-registry/schemas/presentation-definition-claim-format-designations.json" + presentationDefinitionLocation = "https://github.com/decentralized-identity/presentation-exchange/raw/main/schemas/v2.0.0/presentation-definition.json" +) + +func main() { + schemas := []*generate.Schema{parseSchema(claimFormatDescriptorLocation), parseSchema(presentationDefinitionLocation)} + + g := generate.New(schemas...) + + err := g.CreateTypes() + if err != nil { + fmt.Fprintln(os.Stderr, "Failure generating structs: ", err) + os.Exit(1) + } + + f, err := os.OpenFile("../generated.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + + if err != nil { + fmt.Fprintln(os.Stderr, "Error opening output file: ", err) + return + } + + generate.Output(f, g, "pe") +} + +func parseSchema(schemaLocation string) *generate.Schema { + //download claim format descriptor schema + body, err := downloadSchema(schemaLocation) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + schema, err := generate.Parse(string(body), mustParse(schemaLocation)) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + return schema +} + +func downloadSchema(schemaLocation string) ([]byte, error) { + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(schemaLocation) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + if resp.StatusCode != 200 { + fmt.Fprintf(os.Stderr, "expected 200 response, got %v", resp.StatusCode) + os.Exit(1) + } + + // read body + return io.ReadAll(resp.Body) +} + +func mustParse(rawURL string) *url.URL { + pURL, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return pURL +} diff --git a/vcr/pe/types.go b/vcr/pe/types.go new file mode 100644 index 0000000000..d7cd34a9a8 --- /dev/null +++ b/vcr/pe/types.go @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package pe stands for Presentation Exchange which includes Presentation Definition and Presentation Submission +package pe + +// PresentationDefinitionClaimFormatDesignations (replaces generated one) +type PresentationDefinitionClaimFormatDesignations map[string]map[string][]string + +// PresentationSubmission +type PresentationSubmission struct { + Id string `json:"id"` + DefinitionId string `json:"definition_id"` + DescriptorMap []InputDescriptorMappingObject `json:"descriptor_map"` +} + +// InputDescriptorMappingObject +type InputDescriptorMappingObject struct { + Id string `json:"id"` + Path string `json:"path"` + Format string `json:"format"` +} + +// Constraints +type Constraints struct { + Fields []Field `json:"fields,omitempty"` + IsHolder []*IsHolderItems `json:"is_holder,omitempty"` + LimitDisclosure string `json:"limit_disclosure,omitempty"` + SameSubject []*SameSubjectItems `json:"same_subject,omitempty"` + Statuses *Statuses `json:"statuses,omitempty"` + SubjectIsIssuer string `json:"subject_is_issuer,omitempty"` +} + +// Frame +type Frame struct { + AdditionalProperties map[string]interface{} `json:"-,omitempty"` +} + +// InputDescriptor +type InputDescriptor struct { + Constraints *Constraints `json:"constraints"` + Format *PresentationDefinitionClaimFormatDesignations `json:"format,omitempty"` + Group []string `json:"group,omitempty"` + Id string `json:"id"` + Name string `json:"name,omitempty"` + Purpose string `json:"purpose,omitempty"` +} + +// IsHolderItems +type IsHolderItems struct { + Directive string `json:"directive"` + FieldId []string `json:"field_id"` +} + +// PresentationDefinition +type PresentationDefinition struct { + Format *PresentationDefinitionClaimFormatDesignations `json:"format,omitempty"` + Frame *Frame `json:"frame,omitempty"` + Id string `json:"id"` + InputDescriptors []*InputDescriptor `json:"input_descriptors"` + Name string `json:"name,omitempty"` + Purpose string `json:"purpose,omitempty"` + SubmissionRequirements []*SubmissionRequirement `json:"submission_requirements,omitempty"` +} + +// SameSubjectItems +type SameSubjectItems struct { + Directive string `json:"directive"` + FieldId []string `json:"field_id"` +} + +// StatusDirective +type StatusDirective struct { + Directive string `json:"directive,omitempty"` + Type []string `json:"type,omitempty"` +} + +// Statuses +type Statuses struct { + Active *StatusDirective `json:"active,omitempty"` + Revoked *StatusDirective `json:"revoked,omitempty"` + Suspended *StatusDirective `json:"suspended,omitempty"` +} + +// SubmissionRequirement +type SubmissionRequirement struct { +}