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 {
+}