diff --git a/README.rst b/README.rst index f6316b814b..3f908d240f 100644 --- a/README.rst +++ b/README.rst @@ -224,7 +224,7 @@ The following options can be configured on the server: http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. **JSONLD** - jsonld.contexts.localmapping [https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.localmapping [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. **Network** network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. diff --git a/discoveryservice/api/v1/api.go b/discoveryservice/api/v1/api.go new file mode 100644 index 0000000000..2a90d4c29f --- /dev/null +++ b/discoveryservice/api/v1/api.go @@ -0,0 +1,29 @@ +/* + * 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 v1 + +//// lists maps a list name (last path part of use case endpoint) to the list ID +//lists map[string]string +//// name is derived from endpoint: it's the last path part of the definition endpoint +//// It is used to route HTTP GET requests to the correct list. +//pathParts := strings.Split(definition.Endpoint, "/") +//name := pathParts[len(pathParts)-1] +//if name == "" { +//return nil, fmt.Errorf("can't derive list name from definition endpoint: %s", definition.Endpoint) +//} diff --git a/discoveryservice/client.go b/discoveryservice/client.go new file mode 100644 index 0000000000..568f6becef --- /dev/null +++ b/discoveryservice/client.go @@ -0,0 +1,280 @@ +/* + * 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 discoveryservice + +// +//import ( +// "encoding/json" +// "errors" +// "fmt" +// "github.com/google/uuid" +// "github.com/nuts-foundation/go-did/vc" +// "github.com/nuts-foundation/nuts-node/discoveryservice/log" +// "gorm.io/gorm" +// "gorm.io/gorm/clause" +// "io" +// "net/http" +// "net/url" +// "strconv" +// "strings" +// "sync" +// "time" +//) +// +//func newClient(db *gorm.DB, definitions map[string]Definition) (*client, error) { +// result := &client{ +// db: db, +// definitions: definitions, +// } +// if err := initializeSQLStore(db, definitions); err != nil { +// return nil, err +// } +// return result, nil +//} +// +//type client struct { +// db *gorm.DB +// definitions map[string]Definition +//} +// +//func (c *client) Search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) { +// propertyColumns := map[string]string{ +// "id": "cred.credential_id", +// "issuer": "cred.credential_issuer", +// "type": "cred.credential_type", +// "credentialSubject.id": "cred.credential_subject_id", +// } +// +// stmt := c.db.Model(&entry{}). +// Where("usecase_id = ?", serviceID). +// Joins("inner join usecase_client_credential cred ON cred.entry_id = usecase_client_entries.id") +// numProps := 0 +// for jsonPath, value := range query { +// if value == "*" { +// continue +// } +// // sort out wildcard mode +// var eq = "=" +// if strings.HasPrefix(value, "*") { +// value = "%" + value[1:] +// eq = "LIKE" +// } +// if strings.HasSuffix(value, "*") { +// value = value[:len(value)-1] + "%" +// eq = "LIKE" +// } +// if column := propertyColumns[jsonPath]; column != "" { +// stmt = stmt.Where(column+" "+eq+" ?", value) +// } else { +// // This property is not present as column, but indexed as key-value property. +// // Multiple (inner) joins to filter on a dynamic number of properties to filter on is not pretty, but it works +// alias := "p" + strconv.Itoa(numProps) +// numProps++ +// stmt = stmt.Joins("inner join usecase_client_credential_props "+alias+" ON "+alias+".id = cred.id AND "+alias+".key = ? AND "+alias+".value "+eq+" ?", jsonPath, value) +// } +// } +// +// var matches []entry +// if err := stmt.Find(&matches).Error; err != nil { +// return nil, err +// } +// var results []vc.VerifiablePresentation +// for _, match := range matches { +// if match.PresentationExpiration <= time.Now().Unix() { +// continue +// } +// presentation, err := vc.ParseVerifiablePresentation(match.PresentationRaw) +// if err != nil { +// return nil, fmt.Errorf("failed to parse presentation '%s': %w", match.PresentationID, err) +// } +// results = append(results, *presentation) +// } +// return results, nil +//} +// +//func (c *client) refreshAll() { +// wg := &sync.WaitGroup{} +// for _, definition := range c.definitions { +// wg.Add(1) +// go func(definition Definition) { +// c.refreshList(definition) +// }(definition) +// } +// wg.Done() +//} +// +//func (c *client) refreshList(definition Definition) error { +// var currentService discoveryService +// if err := c.db.Find(¤tService, "usecase_id = ?", definition.ID).Error; errors.Is(err, gorm.ErrRecordNotFound) { +// // First refresh of the list +// if err := c.db.Create(&discoveryService{ID: definition.ID}).Error; err != nil { +// return err +// } +// } else if err != nil { +// // Other error +// return err +// } +// log.Logger().Debugf("Refreshing use case list %s", definition.ID) +// // replace with generated client later +// requestURL, _ := url.Parse(definition.Endpoint) +// requestURL.Query().Add("timestamp", fmt.Sprintf("%d", currentService.Timestamp)) +// httpResponse, err := http.Get(definition.Endpoint) +// if err != nil { +// return err +// } +// data, err := io.ReadAll(httpResponse.Body) +// if err != nil { +// return err +// } +// var response ListResponse +// if err = json.Unmarshal(data, &response); err != nil { +// return err +// } +// return c.applyDelta(currentService.UsecaseID, response.Entries, response.Tombstone, currentService.Timestamp, response.Timestamp) +//} +// +//// applyDelta applies the updateTimestamp, retrieved from the use case list server, to the local index of the use case lists. +//func (c *client) applyDelta(usecaseID string, presentations []vc.VerifiablePresentation, tombstoneSet []string, previousTimestamp uint64, timestamp uint64) error { +// // TODO: validate presentations +// if previousTimestamp == timestamp { +// // nothing to do +// return nil +// } +// // We use a transaction to make sure the complete updateTimestamp is applied, or nothing at all. +// // Use a lock on the list to make sure there are no concurrent updates being applied to the list, +// // which could lead to the client becoming out-of-sync with the server list. +// // This situation can only really occur in a distributed system (multiple nodes updating the same list at the same time, with a different timestamp), +// // or bug in the updateTimestamp scheduler. +// return c.db.Transaction(func(tx *gorm.DB) error { +// // Lock the list, check if we're applying the delta to the right starting point +// var currentList list +// if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). +// Where("usecase_id = ?", usecaseID). +// Find(¤tList). +// Error; err != nil { +// return err +// } +// // Make sure we don't apply stale data +// if currentList.Timestamp != previousTimestamp { +// log.Logger().Infof("Not applying delta to use case list '%s': timestamp mismatch (expected %d but was %d). "+ +// "Probably caused by multiple processes updating the list. This is not a problem/bug: stale data should be updated at next refresh.", usecaseID, previousTimestamp, currentList.Timestamp) +// return nil +// } +// // Now we can apply the delta: +// // - delete removed presentations +// // - add new presentations +// // - index the presentations' properties +// if len(tombstoneSet) > 0 { +// if err := tx.Delete(&entry{}, "usecase_id = ? AND presentation_id IN ?", usecaseID, tombstoneSet).Error; err != nil { +// return fmt.Errorf("failed to delete tombstone records: %w", err) +// } +// } +// for _, presentation := range presentations { +// err := c.writePresentation(tx, usecaseID, presentation) +// if err != nil { +// return err +// } +// } +// // Finally, updateTimestamp the list timestamp +// if err := tx.Model(&list{}).Where("usecase_id = ?", usecaseID).Update("timestamp", timestamp).Error; err != nil { +// return fmt.Errorf("failed to updateTimestamp timestamp: %w", err) +// } +// return nil +// }) +//} +// +//func (c *client) writePresentation(tx *gorm.DB, usecaseID string, presentation vc.VerifiablePresentation) error { +// entryID := uuid.NewString() +// // Store list entry / verifiable presentation +// newEntry := entry{ +// ID: entryID, +// UsecaseID: usecaseID, +// PresentationID: presentation.ID.String(), +// PresentationRaw: presentation.Raw(), +// PresentationExpiration: presentation.JWT().Expiration().Unix(), +// } +// // Store the credentials of the presentation +// for _, curr := range presentation.VerifiableCredential { +// var credentialType *string +// for _, currType := range curr.Type { +// if currType.String() != "VerifiableCredential" { +// credentialType = new(string) +// *credentialType = currType.String() +// break +// } +// } +// subjectDID, err := curr.SubjectDID() +// if err != nil { +// return fmt.Errorf("invalid credential subject ID for VP '%s': %w", presentation.ID, err) +// } +// credentialRecordID := uuid.NewString() +// cred := credential{ +// ID: credentialRecordID, +// EntryID: entryID, +// CredentialID: curr.ID.String(), +// CredentialIssuer: curr.Issuer.String(), +// CredentialSubjectID: subjectDID.String(), +// CredentialType: credentialType, +// } +// if len(curr.CredentialSubject) != 1 { +// return errors.New("credential must contain exactly one subject") +// } +// // Store credential properties +// keys, values := indexJSONObject(curr.CredentialSubject[0].(map[string]interface{}), nil, nil, "credentialSubject") +// for i, key := range keys { +// if key == "credentialSubject.id" { +// // present as column, don't index +// continue +// } +// cred.Properties = append(cred.Properties, credentialProperty{ +// ID: credentialRecordID, +// Key: key, +// Value: values[i], +// }) +// } +// newEntry.Credentials = append(newEntry.Credentials, cred) +// } +// if err := tx.Create(&newEntry).Error; err != nil { +// return fmt.Errorf("failed to create entry: %w", err) +// } +// return nil +//} +// +//// indexJSONObject indexes a JSON object, resulting in a slice of JSON paths and corresponding string values. +//// It only traverses JSON objects and only adds string values to the result. +//func indexJSONObject(target map[string]interface{}, jsonPaths []string, stringValues []string, currentPath string) ([]string, []string) { +// for key, value := range target { +// thisPath := currentPath +// if len(thisPath) > 0 { +// thisPath += "." +// } +// thisPath += key +// +// switch typedValue := value.(type) { +// case string: +// jsonPaths = append(jsonPaths, thisPath) +// stringValues = append(stringValues, typedValue) +// case map[string]interface{}: +// jsonPaths, stringValues = indexJSONObject(typedValue, jsonPaths, stringValues, thisPath) +// default: +// // other values (arrays, booleans, numbers, null) are not indexed +// } +// } +// return jsonPaths, stringValues +//} diff --git a/discoveryservice/client_test.go b/discoveryservice/client_test.go new file mode 100644 index 0000000000..160bd7b200 --- /dev/null +++ b/discoveryservice/client_test.go @@ -0,0 +1,457 @@ +/* + * 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 discoveryservice + +// +//import ( +// "context" +// "crypto/ecdsa" +// "crypto/elliptic" +// "crypto/rand" +// "fmt" +// "github.com/google/uuid" +// "github.com/lestrrat-go/jwx/v2/jwa" +// "github.com/lestrrat-go/jwx/v2/jwk" +// "github.com/lestrrat-go/jwx/v2/jws" +// "github.com/lestrrat-go/jwx/v2/jwt" +// ssi "github.com/nuts-foundation/go-did" +// "github.com/nuts-foundation/go-did/did" +// "github.com/nuts-foundation/go-did/vc" +// "github.com/nuts-foundation/nuts-node/storage" +// "github.com/stretchr/testify/require" +// "gorm.io/gorm/schema" +// "testing" +// "time" +//) +// +//func Test_client_applyDelta(t *testing.T) { +// //storageEngine := storage.New() +// //storageEngine.(core.Injectable).Config().(*storage.Config).SQL = storage.SQLConfig{ConnectionString: "file:../../data/sqlite.db"} +// //require.NoError(t, storageEngine.Configure(core.TestServerConfig(core.ServerConfig{Datadir: "data"}))) +// //require.NoError(t, storageEngine.Start()) +// +// storageEngine := storage.NewTestStorageEngine(t) +// require.NoError(t, storageEngine.Start()) +// t.Cleanup(func() { +// _ = storageEngine.Shutdown() +// }) +// +// t.Run("fresh list, assert all persisted fields", func(t *testing.T) { +// c := setupClient(t, storageEngine) +// err := c.applyDelta(TestDefinition.ID, []vc.VerifiablePresentation{vpAlice, vpBob}, []string{"other", "and another"}, 0, 1000) +// require.NoError(t, err) +// +// var actualList list +// require.NoError(t, c.db.Find(&actualList, "usecase_id = ?", TestDefinition.ID).Error) +// require.Equal(t, TestDefinition.ID, actualList.UsecaseID) +// require.Equal(t, uint64(1000), actualList.Timestamp) +// +// var entries []entry +// require.NoError(t, c.db.Find(&entries, "usecase_id = ?", TestDefinition.ID).Error) +// require.Len(t, entries, 2) +// require.Equal(t, vpAlice.ID.String(), entries[0].PresentationID) +// require.Equal(t, vpBob.ID.String(), entries[1].PresentationID) +// }) +//} +// +//func Test_client_writePresentation(t *testing.T) { +// storageEngine := storage.NewTestStorageEngine(t) +// require.NoError(t, storageEngine.Start()) +// t.Cleanup(func() { +// _ = storageEngine.Shutdown() +// }) +// +// t.Run("1 credential", func(t *testing.T) { +// c := setupClient(t, storageEngine) +// err := c.writePresentation(c.db, TestDefinition.ID, vpAlice) +// require.NoError(t, err) +// +// var entries []entry +// require.NoError(t, c.db.Find(&entries, "usecase_id = ?", TestDefinition.ID).Error) +// require.Len(t, entries, 1) +// require.Equal(t, vpAlice.ID.String(), entries[0].PresentationID) +// require.Equal(t, vpAlice.Raw(), entries[0].PresentationRaw) +// require.Equal(t, vpAlice.JWT().Expiration().Unix(), entries[0].PresentationExpiration) +// +// var credentials []credential +// require.NoError(t, c.db.Find(&credentials, "entry_id = ?", entries[0].ID).Error) +// require.Len(t, credentials, 1) +// cred := credentials[0] +// require.Equal(t, vcAlice.ID.String(), cred.CredentialID) +// require.Equal(t, vcAlice.Issuer.String(), cred.CredentialIssuer) +// require.Equal(t, aliceDID.String(), cred.CredentialSubjectID) +// require.Equal(t, vcAlice.Type[1].String(), *cred.CredentialType) +// +// expectedProperties := map[string]map[string]string{ +// cred.ID: { +// "credentialSubject.person.givenName": "Alice", +// "credentialSubject.person.familyName": "Jones", +// "credentialSubject.person.city": "InfoSecLand", +// }, +// } +// for recordID, properties := range expectedProperties { +// for key, value := range properties { +// var prop credentialProperty +// require.NoError(t, c.db.Find(&prop, "id = ? AND key = ?", recordID, key).Error) +// require.Equal(t, value, prop.Value) +// } +// } +// }) +//} +// +//func Test_client_search(t *testing.T) { +// storageEngine := storage.NewTestStorageEngine(t) +// require.NoError(t, storageEngine.Start()) +// t.Cleanup(func() { +// _ = storageEngine.Shutdown() +// }) +// +// type testCase struct { +// name string +// inputVPs []vc.VerifiablePresentation +// query map[string]string +// expectedVPs []string +// } +// testCases := []testCase{ +// { +// name: "issuer", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "issuer": authorityDID.String(), +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "id", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "id": vcAlice.ID.String(), +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "type", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "type": "TestCredential", +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "credentialSubject.id", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "credentialSubject.id": aliceDID.String(), +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "1 property", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "credentialSubject.person.givenName": "Alice", +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "2 properties", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "credentialSubject.person.givenName": "Alice", +// "credentialSubject.person.familyName": "Jones", +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "properties and base properties", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "issuer": authorityDID.String(), +// "credentialSubject.person.givenName": "Alice", +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "wildcard postfix", +// inputVPs: []vc.VerifiablePresentation{vpAlice, vpBob}, +// query: map[string]string{ +// "credentialSubject.person.familyName": "Jo*", +// }, +// expectedVPs: []string{vpAlice.ID.String(), vpBob.ID.String()}, +// }, +// { +// name: "wildcard prefix", +// inputVPs: []vc.VerifiablePresentation{vpAlice, vpBob}, +// query: map[string]string{ +// "credentialSubject.person.givenName": "*ce", +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "wildcard midway (no interpreted as wildcard)", +// inputVPs: []vc.VerifiablePresentation{vpAlice, vpBob}, +// query: map[string]string{ +// "credentialSubject.person.givenName": "A*ce", +// }, +// expectedVPs: []string{}, +// }, +// { +// name: "just wildcard", +// inputVPs: []vc.VerifiablePresentation{vpAlice, vpBob}, +// query: map[string]string{ +// "id": "*", +// }, +// expectedVPs: []string{vpAlice.ID.String(), vpBob.ID.String()}, +// }, +// { +// name: "2 VPs, 1 match", +// inputVPs: []vc.VerifiablePresentation{vpAlice, vpBob}, +// query: map[string]string{ +// "credentialSubject.person.givenName": "Alice", +// }, +// expectedVPs: []string{vpAlice.ID.String()}, +// }, +// { +// name: "multiple matches", +// inputVPs: []vc.VerifiablePresentation{vpAlice, vpBob}, +// query: map[string]string{ +// "issuer": authorityDID.String(), +// }, +// expectedVPs: []string{vpAlice.ID.String(), vpBob.ID.String()}, +// }, +// { +// name: "no match", +// inputVPs: []vc.VerifiablePresentation{vpAlice}, +// query: map[string]string{ +// "credentialSubject.person.givenName": "Bob", +// }, +// expectedVPs: []string{}, +// }, +// { +// name: "empty database", +// query: map[string]string{ +// "credentialSubject.person.givenName": "Bob", +// }, +// expectedVPs: []string{}, +// }, +// } +// +// for _, tc := range testCases { +// t.Run(tc.name, func(t *testing.T) { +// c := setupClient(t, storageEngine) +// for _, vp := range tc.inputVPs { +// err := c.writePresentation(c.db, TestDefinition.ID, vp) +// require.NoError(t, err) +// } +// actualVPs, err := c.Search(TestDefinition.ID, tc.query) +// require.NoError(t, err) +// require.Len(t, actualVPs, len(tc.expectedVPs)) +// for _, expectedVP := range tc.expectedVPs { +// found := false +// for _, actualVP := range actualVPs { +// if actualVP.ID.String() == expectedVP { +// found = true +// break +// } +// } +// require.True(t, found, "expected to find VP with ID %s", expectedVP) +// } +// }) +// } +//} +// +//func setupClient(t *testing.T, storageEngine storage.Engine) *client { +// t.Cleanup(func() { +// underlyingDB, err := storageEngine.GetSQLDatabase().DB() +// require.NoError(t, err) +// tables := []schema.Tabler{ +// &entry{}, +// &credential{}, +// &list{}, +// } +// for _, table := range tables { +// _, err = underlyingDB.Exec("DELETE FROM " + table.TableName()) +// require.NoError(t, err) +// } +// }) +// testDefinitions := map[string]Definition{ +// TestDefinition.ID: TestDefinition, +// } +// +// c, err := newClient(storageEngine.GetSQLDatabase(), testDefinitions) +// require.NoError(t, err) +// return c +//} +// +//var keyPairs map[string]*ecdsa.PrivateKey +//var authorityDID did.DID +//var aliceDID did.DID +//var vcAlice vc.VerifiableCredential +//var vpAlice vc.VerifiablePresentation +//var bobDID did.DID +//var vcBob vc.VerifiableCredential +// +//var vpBob vc.VerifiablePresentation +// +//func init() { +// keyPairs = make(map[string]*ecdsa.PrivateKey) +// authorityDID = did.MustParseDID("did:example:authority") +// keyPairs[authorityDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +// aliceDID = did.MustParseDID("did:example:alice") +// keyPairs[aliceDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +// bobDID = did.MustParseDID("did:example:bob") +// keyPairs[bobDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +// +// vcAlice = createCredentialWithClaims(authorityDID, aliceDID, func() []interface{} { +// return []interface{}{ +// map[string]interface{}{ +// "id": aliceDID.String(), +// "person": map[string]interface{}{ +// "givenName": "Alice", +// "familyName": "Jones", +// "city": "InfoSecLand", +// }, +// }, +// } +// }, func(m map[string]interface{}) { +// // do nothing +// }) +// vpAlice = createPresentation(aliceDID, vcAlice) +// vcBob = createCredentialWithClaims(authorityDID, bobDID, func() []interface{} { +// return []interface{}{ +// map[string]interface{}{ +// "id": aliceDID.String(), +// "person": map[string]interface{}{ +// "givenName": "Bob", +// "familyName": "Johansson", +// "city": "InfoSecLand", +// }, +// }, +// } +// }, func(m map[string]interface{}) { +// // do nothing +// }) +// vpBob = createPresentation(bobDID, vcBob) +//} +// +//func createCredential(issuerDID did.DID, subjectDID did.DID) vc.VerifiableCredential { +// return createCredentialWithClaims(issuerDID, subjectDID, +// func() []interface{} { +// return []interface{}{ +// map[string]interface{}{ +// "id": subjectDID.String(), +// }, +// } +// }, +// func(claims map[string]interface{}) { +// // do nothing +// }) +//} +// +//func createCredentialWithClaims(issuerDID did.DID, subjectDID did.DID, credentialSubjectCreator func() []interface{}, claimVisitor func(map[string]interface{})) vc.VerifiableCredential { +// vcID := did.DIDURL{DID: issuerDID} +// vcID.Fragment = uuid.NewString() +// vcIDURI := vcID.URI() +// expirationDate := time.Now().Add(time.Hour * 24) +// +// result, err := vc.CreateJWTVerifiableCredential(context.Background(), vc.VerifiableCredential{ +// ID: &vcIDURI, +// Issuer: issuerDID.URI(), +// Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("TestCredential")}, +// IssuanceDate: time.Now(), +// ExpirationDate: &expirationDate, +// CredentialSubject: credentialSubjectCreator(), +// }, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { +// claimVisitor(claims) +// return signJWT(subjectDID, claims, headers) +// }) +// if err != nil { +// panic(err) +// } +// return *result +//} +// +//func createPresentation(subjectDID did.DID, credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { +// return createPresentationCustom(subjectDID, func(claims map[string]interface{}) { +// // do nothing +// }, credentials...) +//} +// +//func createPresentationCustom(subjectDID did.DID, claimVisitor func(map[string]interface{}), credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { +// headers := map[string]interface{}{ +// jws.TypeKey: "JWT", +// } +// claims := map[string]interface{}{ +// jwt.IssuerKey: subjectDID.String(), +// jwt.SubjectKey: subjectDID.String(), +// jwt.JwtIDKey: subjectDID.String() + "#" + uuid.NewString(), +// "vp": vc.VerifiablePresentation{ +// Type: append([]ssi.URI{ssi.MustParseURI("VerifiablePresentation")}), +// VerifiableCredential: credentials, +// }, +// jwt.NotBeforeKey: time.Now().Unix(), +// jwt.ExpirationKey: time.Now().Add(time.Hour * 8), +// } +// claimVisitor(claims) +// token, err := signJWT(subjectDID, claims, headers) +// if err != nil { +// panic(err) +// } +// presentation, err := vc.ParseVerifiablePresentation(token) +// if err != nil { +// panic(err) +// } +// return *presentation +//} +// +//func signJWT(subjectDID did.DID, claims map[string]interface{}, headers map[string]interface{}) (string, error) { +// // Build JWK +// signingKey := keyPairs[subjectDID.String()] +// if signingKey == nil { +// return "", fmt.Errorf("key not found for DID: %s", subjectDID) +// } +// subjectKeyJWK, err := jwk.FromRaw(signingKey) +// if err != nil { +// return "", nil +// } +// keyID := did.DIDURL{DID: subjectDID} +// keyID.Fragment = "0" +// if err := subjectKeyJWK.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { +// return "", err +// } +// if err := subjectKeyJWK.Set(jwk.KeyIDKey, keyID.String()); err != nil { +// return "", err +// } +// +// // Build token +// token := jwt.New() +// for k, v := range claims { +// if err := token.Set(k, v); err != nil { +// return "", err +// } +// } +// hdr := jws.NewHeaders() +// for k, v := range headers { +// if err := hdr.Set(k, v); err != nil { +// return "", err +// } +// } +// bytes, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, signingKey, jws.WithProtectedHeaders(hdr))) +// return string(bytes), err +//} diff --git a/discoveryservice/config.go b/discoveryservice/config.go new file mode 100644 index 0000000000..d2dd701a96 --- /dev/null +++ b/discoveryservice/config.go @@ -0,0 +1,50 @@ +/* + * 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 discoveryservice + +// Config holds the config of the module +type Config struct { + Server ServerConfig `koanf:"server"` + Definitions DefinitionsConfig `koanf:"definitions"` +} + +// DefinitionsConfig holds the config for loading Service Definitions. +type DefinitionsConfig struct { + Directory string `koanf:"directory"` +} + +// ServerConfig holds the config for the server +type ServerConfig struct { + // DefinitionIDs specifies which use case lists the server serves. + DefinitionIDs []string `koanf:"definition_ids"` + // Directory is the directory where the server stores the lists. + Directory string `koanf:"directory"` +} + +// DefaultConfig returns the default configuration. +func DefaultConfig() Config { + return Config{ + Server: ServerConfig{}, + } +} + +// IsServer returns true if the node act as Discovery Server. +func (c Config) IsServer() bool { + return len(c.Server.DefinitionIDs) > 0 +} diff --git a/discoveryservice/definition.go b/discoveryservice/definition.go new file mode 100644 index 0000000000..b9af33283b --- /dev/null +++ b/discoveryservice/definition.go @@ -0,0 +1,68 @@ +/* + * 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 discoveryservice + +import ( + "bytes" + "embed" + "encoding/json" + "github.com/nuts-foundation/nuts-node/vcr/pe" + v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" + "github.com/santhosh-tekuri/jsonschema" +) + +//go:embed *.json +var jsonSchemaFiles embed.FS +var definitionJsonSchema *jsonschema.Schema + +func init() { + serviceDefinitionSchemaData, err := jsonSchemaFiles.ReadFile("service-definition-schema.json") + if err != nil { + panic(err) + } + const schemaURL = "http://nuts.nl/schemas/discovery-service-v0.json" + if err := v2.Compiler.AddResource(schemaURL, bytes.NewReader(serviceDefinitionSchemaData)); err != nil { + panic(err) + } + definitionJsonSchema = v2.Compiler.MustCompile(schemaURL) +} + +// Definition holds the definition of a service. +type Definition struct { + // ID is the unique identifier of the use case. + ID string `json:"id"` + // Endpoint is the endpoint where the use case list is served. + Endpoint string `json:"endpoint"` + // PresentationDefinition specifies the Presentation Definition submissions to the list must conform to, + // according to the Presentation Exchange specification. + PresentationDefinition pe.PresentationDefinition `json:"presentation_definition"` + // PresentationMaxValidity specifies how long submitted presentations are allowed to be valid (in seconds). + PresentationMaxValidity int `json:"presentation_max_validity"` +} + +func ParseDefinition(data []byte) (*Definition, error) { + if err := definitionJsonSchema.Validate(bytes.NewReader(data)); err != nil { + return nil, err + } + var definition Definition + if err := json.Unmarshal(data, &definition); err != nil { + return nil, err + } + return &definition, nil +} diff --git a/discoveryservice/interface.go b/discoveryservice/interface.go new file mode 100644 index 0000000000..4a32bcf053 --- /dev/null +++ b/discoveryservice/interface.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 discoveryservice + +import ( + "github.com/nuts-foundation/go-did/vc" +) + +// Timestamp is value that references a point in the list. +// It is used by clients to request new entries since their last query. +// It's implemented as lamport timestamp (https://en.wikipedia.org/wiki/Lamport_timestamp); +// it is incremented when a new entry is added to the list. +// Pass 0 to start at the beginning of the list. +type Timestamp uint64 + +type Server interface { + // Add registers a presentation of the given Discovery Service. + // If the presentation is not valid or it does not conform to the Service Definition, it returns an error. + Add(serviceID string, presentation vc.VerifiablePresentation) error + // Get retrieves the presentations for the given service, starting at the given timestamp. + Get(serviceID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) +} + +type Client interface { + Search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) +} diff --git a/discoveryservice/log/logger.go b/discoveryservice/log/logger.go new file mode 100644 index 0000000000..13914edbaf --- /dev/null +++ b/discoveryservice/log/logger.go @@ -0,0 +1,31 @@ +/* + * 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 log + +import ( + "github.com/nuts-foundation/nuts-node/core" + "github.com/sirupsen/logrus" +) + +var _logger = logrus.StandardLogger().WithField(core.LogFieldModule, "DiscoveryService") + +// Logger returns a logger with the module field set +func Logger() *logrus.Entry { + return _logger +} diff --git a/discoveryservice/mock.go b/discoveryservice/mock.go new file mode 100644 index 0000000000..c0b8b10c9c --- /dev/null +++ b/discoveryservice/mock.go @@ -0,0 +1,107 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: discoveryservice/interface.go +// +// Generated by this command: +// +// mockgen -destination=discoveryservice/mock.go -package=discoveryservice -source=discoveryservice/interface.go +// +// Package discoveryservice is a generated GoMock package. +package discoveryservice + +import ( + reflect "reflect" + + vc "github.com/nuts-foundation/go-did/vc" + gomock "go.uber.org/mock/gomock" +) + +// MockServer is a mock of Server interface. +type MockServer struct { + ctrl *gomock.Controller + recorder *MockServerMockRecorder +} + +// MockServerMockRecorder is the mock recorder for MockServer. +type MockServerMockRecorder struct { + mock *MockServer +} + +// NewMockServer creates a new mock instance. +func NewMockServer(ctrl *gomock.Controller) *MockServer { + mock := &MockServer{ctrl: ctrl} + mock.recorder = &MockServerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockServer) EXPECT() *MockServerMockRecorder { + return m.recorder +} + +// Add mocks base method. +func (m *MockServer) Add(serviceID string, presentation vc.VerifiablePresentation) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", serviceID, presentation) + ret0, _ := ret[0].(error) + return ret0 +} + +// Add indicates an expected call of Add. +func (mr *MockServerMockRecorder) Add(serviceID, presentation any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockServer)(nil).Add), serviceID, presentation) +} + +// Get mocks base method. +func (m *MockServer) Get(serviceID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", serviceID, startAt) + ret0, _ := ret[0].([]vc.VerifiablePresentation) + ret1, _ := ret[1].(*Timestamp) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Get indicates an expected call of Get. +func (mr *MockServerMockRecorder) Get(serviceID, startAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockServer)(nil).Get), serviceID, startAt) +} + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// Search mocks base method. +func (m *MockClient) Search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", serviceID, query) + ret0, _ := ret[0].([]vc.VerifiablePresentation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Search indicates an expected call of Search. +func (mr *MockClientMockRecorder) Search(serviceID, query any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockClient)(nil).Search), serviceID, query) +} diff --git a/discoveryservice/module.go b/discoveryservice/module.go new file mode 100644 index 0000000000..1a4852cb98 --- /dev/null +++ b/discoveryservice/module.go @@ -0,0 +1,232 @@ +/* + * 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 discoveryservice + +import ( + "errors" + "fmt" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/storage" + "os" + "path" + "strings" + "time" +) + +const ModuleName = "DiscoveryService" + +var ErrServerModeDisabled = errors.New("node is not a discovery server for this service") + +var _ core.Injectable = &Module{} +var _ core.Runnable = &Module{} +var _ core.Configurable = &Module{} +var _ Server = &Module{} + +// var _ Client = &Module{} +var retractionPresentationType = ssi.MustParseURI("RetractedVerifiablePresentation") + +func New(storageInstance storage.Engine) *Module { + return &Module{ + storageInstance: storageInstance, + } +} + +type Module struct { + config Config + storageInstance storage.Engine + store *sqlStore + serverDefinitions map[string]Definition + services map[string]Definition +} + +func (m *Module) Configure(_ core.ServerConfig) error { + if m.config.Definitions.Directory == "" { + return nil + } + var err error + m.services, err = loadDefinitions(m.config.Definitions.Directory) + if err != nil { + return err + } + return nil +} + +func (m *Module) Start() error { + var err error + m.store, err = newSQLStore(m.storageInstance.GetSQLDatabase(), m.services) + if err != nil { + return err + } + if len(m.config.Server.DefinitionIDs) > 0 { + // Get the definitions that are enabled for this server + serverDefinitions := make(map[string]Definition) + for _, definitionID := range m.config.Server.DefinitionIDs { + if definition, exists := m.services[definitionID]; !exists { + return fmt.Errorf("definition '%s' not found", definitionID) + } else { + serverDefinitions[definitionID] = definition + } + } + m.serverDefinitions = serverDefinitions + } + return nil +} + +func (m *Module) Shutdown() error { + return nil +} + +func (m *Module) Name() string { + return ModuleName +} + +func (m *Module) Config() interface{} { + return &m.config +} + +func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) error { + definition, exists := m.services[serviceID] + if !exists { + return ErrServiceNotFound + } + if _, isMaintainer := m.serverDefinitions[serviceID]; !isMaintainer { + return ErrServerModeDisabled + } + if presentation.Format() != vc.JWTPresentationProofFormat { + return errors.New("only JWT presentations are supported") + } + // TODO: validate signature + if presentation.ID == nil { + return errors.New("presentation does not have an ID") + } + expiration := presentation.JWT().Expiration() + // VPs should not be valid for too short; unnecessary overhead at the server and clients. + // Also protects against expiration not being set at all. The factor is somewhat arbitrary. + minValidity := float64(definition.PresentationMaxValidity / 4) + if expiration.Sub(time.Now()).Seconds() < minValidity { + return fmt.Errorf("presentation is not valid for long enough (min %s)", time.Duration(minValidity)*time.Second) + } + // VPs should not be valid for too long, as that would prevent the server from pruning them. + if int(expiration.Sub(time.Now()).Seconds()) > definition.PresentationMaxValidity { + return fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second) + } + + if presentation.IsType(retractionPresentationType) { + return m.addRetraction(definition.ID, presentation) + } else { + return m.addPresentation(definition, presentation) + } +} + +func (m *Module) addPresentation(definition Definition, presentation vc.VerifiablePresentation) error { + // Must contain credentials + if len(presentation.VerifiableCredential) == 0 { + return errors.New("presentation must contain at least one credential") + } + // VP can't be valid longer than the credential it contains + expiration := presentation.JWT().Expiration() + for _, cred := range presentation.VerifiableCredential { + exp := cred.JWT().Expiration() + if !exp.IsZero() && expiration.After(exp) { + return fmt.Errorf("presentation is valid longer than the credential(s) it contains") + } + } + // VP must fulfill the PEX Presentation Definition + creds, _, err := definition.PresentationDefinition.Match(presentation.VerifiableCredential) + if err != nil || len(creds) != len(presentation.VerifiableCredential) { + return fmt.Errorf("presentation does not fulfill Presentation Definition: %w", err) + } + return m.store.add(definition.ID, presentation, nil) +} + +func (m *Module) addRetraction(serviceID string, presentation vc.VerifiablePresentation) error { + // Presentation might be a retraction (deletion of an earlier credential) must contain no credentials, and refer to the VP being retracted by ID. + // If those conditions aren't met, we don't need to register the retraction. + if len(presentation.VerifiableCredential) > 0 { + return errors.New("retraction presentation must not contain credentials") + } + // Check that the retraction refers to a presentation that: + // - is owned by the signer (same DID) + // - exists (if not, it might've already been removed due to expiry, or superseeded by a newer presentation) + var retractJTIString string + if retractJTIRaw, ok := presentation.JWT().Get("retract_jti"); !ok { + return errors.New("retraction presentation does not contain 'retract_jti' claim") + } else { + if retractJTIString, ok = retractJTIRaw.(string); !ok { + return errors.New("retraction presentation 'retract_jti' claim is not a string") + } + } + signer := presentation.JWT().Issuer() + signerDID, err := did.ParseDID(signer) + if err != nil { + return fmt.Errorf("retraction presentation issuer is not a valid DID: %w", err) + } + retractJTI, err := did.ParseDIDURL(retractJTIString) + if err != nil { + return fmt.Errorf("retraction presentation 'retract_jti' claim is not a valid DID URL: %w", err) + } + if !signerDID.Equals(retractJTI.DID) { + return errors.New("retraction presentation 'retract_jti' claim does not match JWT issuer") + } + exists, err := m.store.exists(serviceID, signer, retractJTIString) + if err != nil { + return err + } + if !exists { + return errors.New("retraction presentation refers to a non-existing presentation") + } + return m.store.add(serviceID, presentation, nil) +} + +func (m *Module) Get(serviceID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) { + if _, exists := m.services[serviceID]; !exists { + return nil, nil, ErrServiceNotFound + } + return m.store.get(serviceID, startAt) +} + +func loadDefinitions(directory string) (map[string]Definition, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, fmt.Errorf("unable to read definitions directory '%s': %w", directory, err) + } + result := make(map[string]Definition) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + filePath := path.Join(directory, entry.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("unable to read definition file '%s': %w", filePath, err) + } + definition, err := ParseDefinition(data) + if err != nil { + return nil, fmt.Errorf("unable to parse definition file '%s': %w", filePath, err) + } + if _, exists := result[definition.ID]; exists { + return nil, fmt.Errorf("duplicate definition ID '%s' in file '%s'", definition.ID, filePath) + } + result[definition.ID] = *definition + } + return result, nil +} diff --git a/discoveryservice/module_test.go b/discoveryservice/module_test.go new file mode 100644 index 0000000000..a978da620a --- /dev/null +++ b/discoveryservice/module_test.go @@ -0,0 +1,272 @@ +/* + * 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 discoveryservice + +import ( + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +const serviceID = "urn:nuts.nl:usecase:eOverdrachtDev2023" + +func TestModule_Name(t *testing.T) { + assert.Equal(t, "DiscoveryService", (&Module{}).Name()) +} + +func TestModule_Shutdown(t *testing.T) { + assert.NoError(t, (&Module{}).Shutdown()) +} + +func Test_Module_Add(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Run("ok", func(t *testing.T) { + m := setupModule(t, storageEngine) + + err := m.Add(testServiceID, vpAlice) + assert.NoError(t, err) + + _, timestamp, err := m.Get(testServiceID, 0) + require.NoError(t, err) + assert.Equal(t, Timestamp(1), *timestamp) + }) + t.Run("replace presentation of same credential subject", func(t *testing.T) { + m := setupModule(t, storageEngine) + + vpAlice2 := createPresentation(aliceDID, vcAlice) + assert.NoError(t, m.Add(testServiceID, vpAlice)) + assert.NoError(t, m.Add(testServiceID, vpBob)) + assert.NoError(t, m.Add(testServiceID, vpAlice2)) + + presentations, timestamp, err := m.Get(testServiceID, 0) + require.NoError(t, err) + assert.Equal(t, []vc.VerifiablePresentation{vpBob, vpAlice2}, presentations) + assert.Equal(t, Timestamp(3), *timestamp) + }) + t.Run("already exists", func(t *testing.T) { + m := setupModule(t, storageEngine) + + err := m.Add(testServiceID, vpAlice) + assert.NoError(t, err) + err = m.Add(testServiceID, vpAlice) + assert.EqualError(t, err, "presentation already exists") + }) + t.Run("valid for too long", func(t *testing.T) { + m := setupModule(t, storageEngine) + def := m.services[testServiceID] + def.PresentationMaxValidity = 1 + m.services[testServiceID] = def + + err := m.Add(testServiceID, vpAlice) + assert.EqualError(t, err, "presentation is valid for too long (max 1s)") + }) + t.Run("valid longer than its credentials", func(t *testing.T) { + m := setupModule(t, storageEngine) + + vcAlice := createCredentialWithClaims(authorityDID, aliceDID, func(claims map[string]interface{}) { + claims["exp"] = time.Now().Add(time.Hour) + }) + vpAlice := createPresentation(aliceDID, vcAlice) + err := m.Add(testServiceID, vpAlice) + assert.EqualError(t, err, "presentation is valid longer than the credential(s) it contains") + }) + t.Run("not valid long enough", func(t *testing.T) { + m := setupModule(t, storageEngine) + def := m.services[testServiceID] + def.PresentationMaxValidity = int((24 * time.Hour).Seconds() * 365) + m.services[testServiceID] = def + + err := m.Add(testServiceID, vpAlice) + assert.EqualError(t, err, "presentation is not valid for long enough (min 2190h0m0s)") + }) + t.Run("presentation does not contain an ID", func(t *testing.T) { + m := setupModule(t, storageEngine) + + vpWithoutID := createPresentationCustom(aliceDID, func(claims map[string]interface{}, _ *vc.VerifiablePresentation) { + delete(claims, "jti") + }, vcAlice) + err := m.Add(testServiceID, vpWithoutID) + assert.EqualError(t, err, "presentation does not have an ID") + }) + t.Run("not a JWT", func(t *testing.T) { + m := setupModule(t, storageEngine) + err := m.Add(testServiceID, vc.VerifiablePresentation{}) + assert.EqualError(t, err, "only JWT presentations are supported") + }) + t.Run("service unknown", func(t *testing.T) { + m := setupModule(t, storageEngine) + err := m.Add("unknown", vpAlice) + assert.ErrorIs(t, err, ErrServiceNotFound) + }) + + t.Run("retraction", func(t *testing.T) { + vpAliceRetract := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + claims["retract_jti"] = vpAlice.ID.String() + }) + t.Run("ok", func(t *testing.T) { + m := setupModule(t, storageEngine) + err := m.Add(testServiceID, vpAlice) + require.NoError(t, err) + err = m.Add(testServiceID, vpAliceRetract) + assert.NoError(t, err) + }) + t.Run("non-existent presentation", func(t *testing.T) { + m := setupModule(t, storageEngine) + err := m.Add(testServiceID, vpAliceRetract) + assert.EqualError(t, err, "retraction presentation refers to a non-existing presentation") + }) + t.Run("must not contain credentials", func(t *testing.T) { + m := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + }, vcAlice) + err := m.Add(testServiceID, vp) + assert.EqualError(t, err, "retraction presentation must not contain credentials") + }) + t.Run("missing 'retract_jti' claim", func(t *testing.T) { + m := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(_ map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + }) + err := m.Add(testServiceID, vp) + assert.EqualError(t, err, "retraction presentation does not contain 'retract_jti' claim") + }) + t.Run("'retract_jti' claim in not a string", func(t *testing.T) { + m := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + claims["retract_jti"] = 10 + }) + err := m.Add(testServiceID, vp) + assert.EqualError(t, err, "retraction presentation 'retract_jti' claim is not a string") + }) + t.Run("'retract_jti' claim in not a valid DID", func(t *testing.T) { + m := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + claims["retract_jti"] = "not a DID" + }) + err := m.Add(testServiceID, vp) + assert.EqualError(t, err, "retraction presentation 'retract_jti' claim is not a valid DID URL: invalid DID") + }) + t.Run("'retract_jti' claim does not reference a presentation of the signer", func(t *testing.T) { + m := setupModule(t, storageEngine) + vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + vp.Type = append(vp.Type, retractionPresentationType) + claims["retract_jti"] = bobDID.String() + }) + err := m.Add(testServiceID, vp) + assert.EqualError(t, err, "retraction presentation 'retract_jti' claim does not match JWT issuer") + }) + }) +} + +func Test_Module_Get(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("empty list, empty timestamp", func(t *testing.T) { + m := setupModule(t, storageEngine) + presentations, timestamp, err := m.Get(testServiceID, 0) + assert.NoError(t, err) + assert.Empty(t, presentations) + assert.Empty(t, timestamp) + }) + t.Run("1 entry, empty timestamp", func(t *testing.T) { + m := setupModule(t, storageEngine) + require.NoError(t, m.Add(testServiceID, vpAlice)) + presentations, timestamp, err := m.Get(testServiceID, 0) + assert.NoError(t, err) + assert.Equal(t, []vc.VerifiablePresentation{vpAlice}, presentations) + assert.Equal(t, Timestamp(1), *timestamp) + }) + t.Run("2 entries, empty timestamp", func(t *testing.T) { + m := setupModule(t, storageEngine) + require.NoError(t, m.Add(testServiceID, vpAlice)) + require.NoError(t, m.Add(testServiceID, vpBob)) + presentations, timestamp, err := m.Get(testServiceID, 0) + assert.NoError(t, err) + assert.Equal(t, []vc.VerifiablePresentation{vpAlice, vpBob}, presentations) + assert.Equal(t, Timestamp(2), *timestamp) + }) + t.Run("2 entries, start after first", func(t *testing.T) { + m := setupModule(t, storageEngine) + require.NoError(t, m.Add(testServiceID, vpAlice)) + require.NoError(t, m.Add(testServiceID, vpBob)) + presentations, timestamp, err := m.Get(testServiceID, 1) + assert.NoError(t, err) + assert.Equal(t, []vc.VerifiablePresentation{vpBob}, presentations) + assert.Equal(t, Timestamp(2), *timestamp) + }) + t.Run("2 entries, start after end", func(t *testing.T) { + m := setupModule(t, storageEngine) + require.NoError(t, m.Add(testServiceID, vpAlice)) + require.NoError(t, m.Add(testServiceID, vpBob)) + presentations, timestamp, err := m.Get(testServiceID, 2) + assert.NoError(t, err) + assert.Equal(t, []vc.VerifiablePresentation{}, presentations) + assert.Equal(t, Timestamp(2), *timestamp) + }) + t.Run("service unknown", func(t *testing.T) { + m := setupModule(t, storageEngine) + _, _, err := m.Get("unknown", 0) + assert.ErrorIs(t, err, ErrServiceNotFound) + }) +} + +func setupModule(t *testing.T, storageInstance storage.Engine) *Module { + resetStoreAfterTest(t, storageInstance.GetSQLDatabase()) + m := New(storageInstance) + require.NoError(t, m.Configure(core.ServerConfig{})) + m.services = testDefinitions() + m.serverDefinitions = map[string]Definition{ + testServiceID: m.services[testServiceID], + } + require.NoError(t, m.Start()) + return m +} + +func Test_loadDefinitions(t *testing.T) { + t.Run("duplicate ID", func(t *testing.T) { + definitions, err := loadDefinitions("test/duplicate_id") + assert.EqualError(t, err, "duplicate definition ID 'urn:nuts.nl:usecase:eOverdrachtDev2023' in file 'test/duplicate_id/2.json'") + assert.Nil(t, definitions) + }) + t.Run("invalid JSON", func(t *testing.T) { + definitions, err := loadDefinitions("test/invalid_json") + assert.ErrorContains(t, err, "unable to parse definition file 'test/invalid_json/1.json'") + assert.Nil(t, definitions) + }) + t.Run("invalid definition", func(t *testing.T) { + definitions, err := loadDefinitions("test/invalid_definition") + assert.ErrorContains(t, err, "unable to parse definition file 'test/invalid_definition/1.json'") + assert.Nil(t, definitions) + }) + t.Run("non-existent directory", func(t *testing.T) { + definitions, err := loadDefinitions("test/non_existent") + assert.ErrorContains(t, err, "unable to read definitions directory 'test/non_existent'") + assert.Nil(t, definitions) + }) +} diff --git a/discoveryservice/service-definition-schema.json b/discoveryservice/service-definition-schema.json new file mode 100644 index 0000000000..b1b3bc177e --- /dev/null +++ b/discoveryservice/service-definition-schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Service Definition", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "endpoint": { + "type": "string", + "minLength": 1 + }, + "presentation_max_validity": { + "type": "integer", + "minimum": 1 + }, + "presentation_definition": { + "$ref": "http://identity.foundation/presentation-exchange/schemas/presentation-definition.json" + } + }, + "required": [ + "id", + "endpoint", + "presentation_max_validity", + "presentation_definition" + ] +} \ No newline at end of file diff --git a/discoveryservice/store.go b/discoveryservice/store.go new file mode 100644 index 0000000000..dd53898e9a --- /dev/null +++ b/discoveryservice/store.go @@ -0,0 +1,230 @@ +/* + * 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 discoveryservice + +import ( + "errors" + "fmt" + "github.com/google/uuid" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/discoveryservice/log" + credential2 "github.com/nuts-foundation/nuts-node/vcr/credential" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + "time" +) + +var ErrServiceNotFound = errors.New("discovery service not found") +var ErrPresentationAlreadyExists = errors.New("presentation already exists") + +type discoveryService struct { + ID string `gorm:"primaryKey"` + Timestamp uint64 +} + +func (s discoveryService) TableName() string { + return "discoveryservices" +} + +var _ schema.Tabler = (*servicePresentation)(nil) + +type servicePresentation struct { + ID string `gorm:"primaryKey"` + ServiceID string + Timestamp uint64 + CredentialSubjectID string + PresentationID string + PresentationRaw string + PresentationExpiration int64 + Credentials []credential `gorm:"foreignKey:PresentationID;references:ID"` +} + +func (s servicePresentation) TableName() string { + return "discoveryservice_presentations" +} + +// credential is a Verifiable Credential, part of a presentation (entry) on a use case list. +type credential struct { + // ID is the unique identifier of the entry. + ID string `gorm:"primaryKey"` + // PresentationID corresponds to the discoveryservice_presentations record ID (not VerifiablePresentation.ID) this credential belongs to. + PresentationID string + // CredentialID contains the 'id' property of the Verifiable Credential. + CredentialID string + // CredentialIssuer contains the 'issuer' property of the Verifiable Credential. + CredentialIssuer string + // CredentialSubjectID contains the 'credentialSubject.id' property of the Verifiable Credential. + CredentialSubjectID string + // CredentialType contains the 'type' property of the Verifiable Credential (not being 'VerifiableCredential'). + CredentialType *string + Properties []credentialProperty `gorm:"foreignKey:ID;references:ID"` +} + +// TableName returns the table name for this DTO. +func (p credential) TableName() string { + return "discoveryservice_credentials" +} + +// credentialProperty is a property of a Verifiable Credential in a Verifiable Presentation in a discovery service. +type credentialProperty struct { + // ID refers to the entry record in discoveryservice_credentials + ID string `gorm:"primaryKey"` + // Key is JSON path of the property. + Key string `gorm:"primaryKey"` + // Value is the value of the property. + Value string +} + +// TableName returns the table name for this DTO. +func (l credentialProperty) TableName() string { + return "discoveryservice_credential_props" +} + +type sqlStore struct { + db *gorm.DB +} + +func newSQLStore(db *gorm.DB, definitions map[string]Definition) (*sqlStore, error) { + // Creates entries in the discovery service table with initial timestamp, if they don't exist yet + for _, definition := range definitions { + currentList := discoveryService{ + ID: definition.ID, + } + if err := db.FirstOrCreate(¤tList, "id = ?", definition.ID).Error; err != nil { + return nil, err + } + } + return &sqlStore{ + db: db, + }, nil +} + +// Add adds a presentation to the list of presentations. +// Timestamp should be passed if the presentation was received from a remote Discovery Server, then it is stored alongside the presentation. +// If the local node is the Discovery Server and thus is responsible for the timestamping, +// nil should be passed to let the store determine the right value. +func (s *sqlStore) add(serviceID string, presentation vc.VerifiablePresentation, timestamp *Timestamp) error { + credentialSubjectID, err := credential2.PresentationSigner(presentation) + if err != nil { + return err + } + if exists, err := s.exists(serviceID, credentialSubjectID.String(), presentation.ID.String()); err != nil { + return err + } else if exists { + return ErrPresentationAlreadyExists + } + if err := s.prune(); err != nil { + return err + } + + return s.db.Transaction(func(tx *gorm.DB) error { + timestamp, err := s.updateTimestamp(tx, serviceID, timestamp) + if err != nil { + return err + } + // Delete any previous presentations of the subject + if err := tx.Delete(&servicePresentation{}, "service_id = ? AND credential_subject_id = ?", serviceID, credentialSubjectID.String()). + Error; err != nil { + return err + } + // Now store the presentation itself + return tx.Create(&servicePresentation{ + ID: uuid.NewString(), + ServiceID: serviceID, + Timestamp: uint64(timestamp), + CredentialSubjectID: credentialSubjectID.String(), + PresentationID: presentation.ID.String(), + PresentationRaw: presentation.Raw(), + PresentationExpiration: presentation.JWT().Expiration().Unix(), + }).Error + }) +} + +func (s *sqlStore) get(serviceID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) { + var rows []servicePresentation + err := s.db.Order("timestamp ASC").Find(&rows, "service_id = ? AND timestamp > ?", serviceID, int(startAt)).Error + if err != nil { + return nil, nil, fmt.Errorf("query service '%s': %w", serviceID, err) + } + timestamp := startAt + presentations := make([]vc.VerifiablePresentation, 0, len(rows)) + for _, row := range rows { + presentation, err := vc.ParseVerifiablePresentation(row.PresentationRaw) + if err != nil { + return nil, nil, fmt.Errorf("parse presentation '%s' of service '%s': %w", row.PresentationID, serviceID, err) + } + presentations = append(presentations, *presentation) + timestamp = Timestamp(row.Timestamp) + } + return presentations, ×tamp, nil +} + +func (s *sqlStore) updateTimestamp(tx *gorm.DB, serviceID string, newTimestamp *Timestamp) (Timestamp, error) { + var result discoveryService + // Lock (SELECT FOR UPDATE) discoveryservices row to prevent concurrent updates to the same list, which could mess up the lamport timestamp. + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where(discoveryService{ID: serviceID}). + Find(&result). + Error; err != nil { + return 0, err + } + result.ID = serviceID + if newTimestamp == nil { + // Increment timestamp + result.Timestamp++ + } else { + result.Timestamp = uint64(*newTimestamp) + } + if err := tx.Save(&result).Error; err != nil { + return 0, err + } + return Timestamp(result.Timestamp), nil +} + +func (s *sqlStore) exists(serviceID string, credentialSubjectID string, presentationID string) (bool, error) { + var count int64 + if err := s.db.Model(servicePresentation{}).Where(servicePresentation{ + ServiceID: serviceID, + CredentialSubjectID: credentialSubjectID, + PresentationID: presentationID, + }).Count(&count).Error; err != nil { + return false, fmt.Errorf("check presentation existence: %w", err) + } + return count > 0, nil +} + +func (s *sqlStore) prune() error { + num, err := s.removeExpired() + if err != nil { + return err + } + if num > 0 { + log.Logger().Debugf("Pruned %d expired presentations", num) + } + return nil +} + +func (s *sqlStore) removeExpired() (int, error) { + result := s.db.Where("presentation_expiration < ?", time.Now().Unix()).Delete(servicePresentation{}) + if result.Error != nil { + return 0, fmt.Errorf("prune presentations: %w", result.Error) + } + return int(result.RowsAffected), nil +} diff --git a/discoveryservice/store_test.go b/discoveryservice/store_test.go new file mode 100644 index 0000000000..1627a6d446 --- /dev/null +++ b/discoveryservice/store_test.go @@ -0,0 +1,78 @@ +/* + * 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 discoveryservice + +import ( + "github.com/nuts-foundation/nuts-node/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "testing" +) + +func Test_sqlStore_exists(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("empty list", func(t *testing.T) { + m := setupStore(t, storageEngine.GetSQLDatabase()) + exists, err := m.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) + assert.NoError(t, err) + assert.False(t, exists) + }) + t.Run("non-empty list, no match (other subject and ID)", func(t *testing.T) { + m := setupStore(t, storageEngine.GetSQLDatabase()) + require.NoError(t, m.add(testServiceID, vpBob, nil)) + exists, err := m.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) + assert.NoError(t, err) + assert.False(t, exists) + }) + t.Run("non-empty list, no match (other list)", func(t *testing.T) { + m := setupStore(t, storageEngine.GetSQLDatabase()) + require.NoError(t, m.add(testServiceID, vpAlice, nil)) + exists, err := m.exists("other", aliceDID.String(), vpAlice.ID.String()) + assert.NoError(t, err) + assert.False(t, exists) + }) + t.Run("non-empty list, match", func(t *testing.T) { + m := setupStore(t, storageEngine.GetSQLDatabase()) + require.NoError(t, m.add(testServiceID, vpAlice, nil)) + exists, err := m.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) + assert.NoError(t, err) + assert.True(t, exists) + }) +} + +func setupStore(t *testing.T, db *gorm.DB) *sqlStore { + resetStoreAfterTest(t, db) + store, err := newSQLStore(db, testDefinitions()) + require.NoError(t, err) + return store +} + +func resetStoreAfterTest(t *testing.T, db *gorm.DB) { + t.Cleanup(func() { + underlyingDB, err := db.DB() + require.NoError(t, err) + _, err = underlyingDB.Exec("DELETE FROM discoveryservice_presentations") + require.NoError(t, err) + _, err = underlyingDB.Exec("DELETE FROM discoveryservices") + require.NoError(t, err) + }) +} diff --git a/discoveryservice/test.go b/discoveryservice/test.go new file mode 100644 index 0000000000..43f74082f8 --- /dev/null +++ b/discoveryservice/test.go @@ -0,0 +1,211 @@ +/* + * 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 discoveryservice + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "time" +) + +var keyPairs map[string]*ecdsa.PrivateKey +var authorityDID did.DID +var aliceDID did.DID +var vcAlice vc.VerifiableCredential +var vpAlice vc.VerifiablePresentation +var bobDID did.DID +var vcBob vc.VerifiableCredential +var vpBob vc.VerifiablePresentation + +var testServiceID = "usecase_v1" + +func testDefinitions() map[string]Definition { + return map[string]Definition{ + testServiceID: { + ID: testServiceID, + Endpoint: "http://example.com/usecase", + PresentationDefinition: pe.PresentationDefinition{ + InputDescriptors: []*pe.InputDescriptor{ + { + Constraints: &pe.Constraints{ + Fields: []pe.Field{ + { + Path: []string{"$.issuer"}, + Filter: &pe.Filter{ + Type: "string", + }, + }, + }, + }, + }, + }, + }, + PresentationMaxValidity: int((24 * time.Hour).Seconds()), + }, + "other": { + ID: "other", + Endpoint: "http://example.com/other", + PresentationDefinition: pe.PresentationDefinition{ + InputDescriptors: []*pe.InputDescriptor{ + { + Constraints: &pe.Constraints{ + Fields: []pe.Field{ + { + Path: []string{"$.issuer"}, + Filter: &pe.Filter{ + Type: "string", + }, + }, + }, + }, + }, + }, + }, + PresentationMaxValidity: int((24 * time.Hour).Seconds()), + }, + } +} + +func init() { + keyPairs = make(map[string]*ecdsa.PrivateKey) + authorityDID = did.MustParseDID("did:example:authority") + keyPairs[authorityDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + aliceDID = did.MustParseDID("did:example:alice") + keyPairs[aliceDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + bobDID = did.MustParseDID("did:example:bob") + keyPairs[bobDID.String()], _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + vcAlice = createCredential(authorityDID, aliceDID) + vpAlice = createPresentation(aliceDID, vcAlice) + vcBob = createCredential(authorityDID, bobDID) + vpBob = createPresentation(bobDID, vcBob) +} + +func createCredential(issuerDID did.DID, subjectDID did.DID) vc.VerifiableCredential { + return createCredentialWithClaims(issuerDID, subjectDID, func(claims map[string]interface{}) { + // do nothing + }) +} + +func createCredentialWithClaims(issuerDID did.DID, subjectDID did.DID, claimVisitor func(map[string]interface{})) vc.VerifiableCredential { + vcID := did.DIDURL{DID: issuerDID} + vcID.Fragment = uuid.NewString() + vcIDURI := vcID.URI() + expirationDate := time.Now().Add(time.Hour * 24) + result, err := vc.CreateJWTVerifiableCredential(context.Background(), vc.VerifiableCredential{ + ID: &vcIDURI, + Issuer: issuerDID.URI(), + IssuanceDate: time.Now(), + ExpirationDate: &expirationDate, + CredentialSubject: []interface{}{ + map[string]interface{}{ + "id": subjectDID.String(), + }, + }, + }, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + claimVisitor(claims) + return signJWT(subjectDID, claims, headers) + }) + if err != nil { + panic(err) + } + return *result +} + +func createPresentation(subjectDID did.DID, credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { + return createPresentationCustom(subjectDID, func(_ map[string]interface{}, _ *vc.VerifiablePresentation) { + // do nothing + }, credentials...) +} + +func createPresentationCustom(subjectDID did.DID, visitor func(claims map[string]interface{}, vp *vc.VerifiablePresentation), credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { + headers := map[string]interface{}{ + jws.TypeKey: "JWT", + } + innerVP := &vc.VerifiablePresentation{ + Type: append([]ssi.URI{ssi.MustParseURI("VerifiablePresentation")}), + VerifiableCredential: credentials, + } + claims := map[string]interface{}{ + jwt.IssuerKey: subjectDID.String(), + jwt.SubjectKey: subjectDID.String(), + jwt.JwtIDKey: subjectDID.String() + "#" + uuid.NewString(), + jwt.NotBeforeKey: time.Now().Unix(), + jwt.ExpirationKey: time.Now().Add(time.Hour * 8), + } + visitor(claims, innerVP) + claims["vp"] = *innerVP + token, err := signJWT(subjectDID, claims, headers) + if err != nil { + panic(err) + } + presentation, err := vc.ParseVerifiablePresentation(token) + if err != nil { + panic(err) + } + return *presentation +} + +func signJWT(subjectDID did.DID, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + // Build JWK + signingKey := keyPairs[subjectDID.String()] + if signingKey == nil { + return "", fmt.Errorf("key not found for DID: %s", subjectDID) + } + subjectKeyJWK, err := jwk.FromRaw(signingKey) + if err != nil { + return "", nil + } + keyID := did.DIDURL{DID: subjectDID} + keyID.Fragment = "0" + if err := subjectKeyJWK.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { + return "", err + } + if err := subjectKeyJWK.Set(jwk.KeyIDKey, keyID.String()); err != nil { + return "", err + } + + // Build token + token := jwt.New() + for k, v := range claims { + if err := token.Set(k, v); err != nil { + return "", err + } + } + hdr := jws.NewHeaders() + for k, v := range headers { + if err := hdr.Set(k, v); err != nil { + return "", err + } + } + bytes, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, signingKey, jws.WithProtectedHeaders(hdr))) + return string(bytes), err +} diff --git a/discoveryservice/test/duplicate_id/1.json b/discoveryservice/test/duplicate_id/1.json new file mode 100644 index 0000000000..533dc08feb --- /dev/null +++ b/discoveryservice/test/duplicate_id/1.json @@ -0,0 +1,49 @@ +{ + "id": "urn:nuts.nl:usecase:eOverdrachtDev2023", + "endpoint": "https://example.com/usecase/eoverdracht_dev", + "presentation_max_validity": 36000, + "presentation_definition": { + "id": "pd_eoverdracht_dev_care_organization", + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + }, + "input_descriptors": [ + { + "id": "id_nuts_care_organization_cred", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + }, + { + "path": [ + "$.credentialSubject.organization.name" + ], + "filter": { + "type": "string" + } + }, + { + "path": [ + "$.credentialSubject.organization.city" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } +} diff --git a/discoveryservice/test/duplicate_id/2.json b/discoveryservice/test/duplicate_id/2.json new file mode 100644 index 0000000000..533dc08feb --- /dev/null +++ b/discoveryservice/test/duplicate_id/2.json @@ -0,0 +1,49 @@ +{ + "id": "urn:nuts.nl:usecase:eOverdrachtDev2023", + "endpoint": "https://example.com/usecase/eoverdracht_dev", + "presentation_max_validity": 36000, + "presentation_definition": { + "id": "pd_eoverdracht_dev_care_organization", + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + }, + "input_descriptors": [ + { + "id": "id_nuts_care_organization_cred", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + }, + { + "path": [ + "$.credentialSubject.organization.name" + ], + "filter": { + "type": "string" + } + }, + { + "path": [ + "$.credentialSubject.organization.city" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } +} diff --git a/discoveryservice/test/duplicate_id/README.md b/discoveryservice/test/duplicate_id/README.md new file mode 100644 index 0000000000..f0fc1802ed --- /dev/null +++ b/discoveryservice/test/duplicate_id/README.md @@ -0,0 +1 @@ +This directory contains an invalid use case definition: 2 definitions have the same ID. \ No newline at end of file diff --git a/discoveryservice/test/invalid_definition/1.json b/discoveryservice/test/invalid_definition/1.json new file mode 100644 index 0000000000..0db3279e44 --- /dev/null +++ b/discoveryservice/test/invalid_definition/1.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/discoveryservice/test/invalid_definition/README.md b/discoveryservice/test/invalid_definition/README.md new file mode 100644 index 0000000000..6053c6729b --- /dev/null +++ b/discoveryservice/test/invalid_definition/README.md @@ -0,0 +1 @@ +This directory contains an invalid use case definition: it does not fields that are required according to the JSON schema. \ No newline at end of file diff --git a/discoveryservice/test/invalid_json/1.json b/discoveryservice/test/invalid_json/1.json new file mode 100644 index 0000000000..7e31dc3cad --- /dev/null +++ b/discoveryservice/test/invalid_json/1.json @@ -0,0 +1 @@ +this is not JSON \ No newline at end of file diff --git a/discoveryservice/test/invalid_json/README.md b/discoveryservice/test/invalid_json/README.md new file mode 100644 index 0000000000..30610e3784 --- /dev/null +++ b/discoveryservice/test/invalid_json/README.md @@ -0,0 +1 @@ +This directory contains an invalid use case definition: it is not valid JSON. \ No newline at end of file diff --git a/discoveryservice/test/valid/eoverdracht.json b/discoveryservice/test/valid/eoverdracht.json new file mode 100644 index 0000000000..533dc08feb --- /dev/null +++ b/discoveryservice/test/valid/eoverdracht.json @@ -0,0 +1,49 @@ +{ + "id": "urn:nuts.nl:usecase:eOverdrachtDev2023", + "endpoint": "https://example.com/usecase/eoverdracht_dev", + "presentation_max_validity": 36000, + "presentation_definition": { + "id": "pd_eoverdracht_dev_care_organization", + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + }, + "input_descriptors": [ + { + "id": "id_nuts_care_organization_cred", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + }, + { + "path": [ + "$.credentialSubject.organization.name" + ], + "filter": { + "type": "string" + } + }, + { + "path": [ + "$.credentialSubject.organization.city" + ], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } +} diff --git a/discoveryservice/test/valid/subdir/README.md b/discoveryservice/test/valid/subdir/README.md new file mode 100644 index 0000000000..b1778a548c --- /dev/null +++ b/discoveryservice/test/valid/subdir/README.md @@ -0,0 +1 @@ +This directory (with an invalid definition) is there to assert subdirectories are not traversed. \ No newline at end of file diff --git a/discoveryservice/test/valid/subdir/empty.json b/discoveryservice/test/valid/subdir/empty.json new file mode 100644 index 0000000000..2c63c08510 --- /dev/null +++ b/discoveryservice/test/valid/subdir/empty.json @@ -0,0 +1,2 @@ +{ +} diff --git a/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index 0bf3c272ea..ca69be9aee 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -44,7 +44,7 @@ The following options apply to the server commands below: --http.default.log string What to log about HTTP requests. Options are 'nothing', 'metadata' (log request method, URI, IP and response code), and 'metadata-and-body' (log the request and response body, in addition to the metadata). (default "metadata") --http.default.tls string Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', --internalratelimiter When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. (default true) - --jsonld.contexts.localmapping stringToString This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. (default [https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson]) + --jsonld.contexts.localmapping stringToString This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. (default [https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson]) --jsonld.contexts.remoteallowlist strings In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. (default [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json]) --loggerformat string Log format (text, json) (default "text") --network.bootstrapnodes strings List of bootstrap nodes (':') which the node initially connect to. diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 03b274bb46..fbe48394e1 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -50,7 +50,7 @@ http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. **JSONLD** - jsonld.contexts.localmapping [https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.localmapping [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. **Network** network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. diff --git a/go.mod b/go.mod index 896b9cf3f9..594e72dbe2 100644 --- a/go.mod +++ b/go.mod @@ -106,6 +106,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/gorm v1.9.16 // indirect github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.17.2 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/makefile b/makefile index 6f6efae5be..4c92cfa65c 100644 --- a/makefile +++ b/makefile @@ -19,6 +19,7 @@ gen-mocks: mockgen -destination=crypto/mock.go -package=crypto -source=crypto/interface.go mockgen -destination=crypto/storage/spi/mock.go -package spi -source=crypto/storage/spi/interface.go mockgen -destination=didman/mock.go -package=didman -source=didman/types.go + mockgen -destination=discoveryservice/mock.go -package=discoveryservice -source=discoveryservice/interface.go mockgen -destination=events/events_mock.go -package=events -source=events/interface.go Event mockgen -destination=events/mock.go -package=events -source=events/conn.go Conn ConnectionPool mockgen -destination=http/echo_mock.go -package=http -source=http/echo.go -imports echo=github.com/labstack/echo/v4 @@ -55,6 +56,7 @@ gen-mocks: mockgen -destination=vdr/management/management_mock.go -package=management -source=vdr/management/management.go mockgen -destination=vdr/management/finder_mock.go -package=management -source=vdr/management/finder.go + gen-api: oapi-codegen --config codegen/configs/common_ssi_types.yaml docs/_static/common/ssi_types.yaml | gofmt > api/ssi_types.go oapi-codegen --config codegen/configs/crypto_v1.yaml -package v1 docs/_static/crypto/v1.yaml | gofmt > crypto/api/v1/generated.go diff --git a/storage/mock.go b/storage/mock.go index a4aa1c0bfa..ecbfce1b17 100644 --- a/storage/mock.go +++ b/storage/mock.go @@ -69,32 +69,32 @@ func (mr *MockEngineMockRecorder) GetProvider(moduleName any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProvider", reflect.TypeOf((*MockEngine)(nil).GetProvider), moduleName) } -// GetSessionDatabase mocks base method. -func (m *MockEngine) GetSessionDatabase() SessionDatabase { +// GetSQLDatabase mocks base method. +func (m *MockEngine) GetSQLDatabase() *gorm.DB { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSessionDatabase") - ret0, _ := ret[0].(SessionDatabase) + ret := m.ctrl.Call(m, "GetSQLDatabase") + ret0, _ := ret[0].(*gorm.DB) return ret0 } -// GetSessionDatabase indicates an expected call of GetSessionDatabase. -func (mr *MockEngineMockRecorder) GetSessionDatabase() *gomock.Call { +// GetSQLDatabase indicates an expected call of GetSQLDatabase. +func (mr *MockEngineMockRecorder) GetSQLDatabase() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSessionDatabase", reflect.TypeOf((*MockEngine)(nil).GetSessionDatabase)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSQLDatabase", reflect.TypeOf((*MockEngine)(nil).GetSQLDatabase)) } -// SQLDatabase mocks base method. -func (m *MockEngine) GetSQLDatabase() *gorm.DB { +// GetSessionDatabase mocks base method. +func (m *MockEngine) GetSessionDatabase() SessionDatabase { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSQLDatabase") - ret0, _ := ret[0].(*gorm.DB) + ret := m.ctrl.Call(m, "GetSessionDatabase") + ret0, _ := ret[0].(SessionDatabase) return ret0 } -// SQLDatabase indicates an expected call of SQLDatabase. -func (mr *MockEngineMockRecorder) SQLDatabase() *gomock.Call { +// GetSessionDatabase indicates an expected call of GetSessionDatabase. +func (mr *MockEngineMockRecorder) GetSessionDatabase() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSQLDatabase", reflect.TypeOf((*MockEngine)(nil).GetSQLDatabase)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSessionDatabase", reflect.TypeOf((*MockEngine)(nil).GetSessionDatabase)) } // Shutdown mocks base method. diff --git a/storage/sql_migrations/2_discoveryservice.down.sql b/storage/sql_migrations/2_discoveryservice.down.sql new file mode 100644 index 0000000000..3922d7112b --- /dev/null +++ b/storage/sql_migrations/2_discoveryservice.down.sql @@ -0,0 +1,4 @@ +drop table discoveryservices; +drop table discoveryservice_presentations; +drop table discoveryservice_credentials; +drop table discoveryservice_credential_props; \ No newline at end of file diff --git a/storage/sql_migrations/2_discoveryservice.up.sql b/storage/sql_migrations/2_discoveryservice.up.sql new file mode 100644 index 0000000000..e1356044ac --- /dev/null +++ b/storage/sql_migrations/2_discoveryservice.up.sql @@ -0,0 +1,50 @@ +-- discoveryservices contains the known discovery services and the highest timestamp +create table discoveryservices +( + id text not null primary key, + timestamp integer not null +); + +-- discoveryservice_presentations contains the presentations of the discovery services +create table discoveryservice_presentations +( + id text not null primary key, + service_id text not null, + timestamp integer not null, + credential_subject_id text not null, + presentation_id text not null, + presentation_raw text not null, + presentation_expiration integer not null, + deleted boolean not null default false, + unique (service_id, credential_subject_id), + constraint fk_discovery_presentation_service_id foreign key (service_id) references discoveryservices (id) on delete cascade +); + +-- discoveryservice_credentials is a credential in a presentation of the discovery service. +-- We could do without the table, but having it allows to have a normalized index for credential properties that appear on every credential. +-- Then we don't need rows in the properties table for them (having a column for those is faster than having a row in the properties table which needs to be joined). +create table discoveryservice_credentials +( + id text not null primary key, + presentation_id text not null, + credential_id text not null, + credential_issuer text not null, + credential_subject_id text not null, + -- for now, credentials with at most 2 types are supported. + -- The type stored in the type column will be the 'other' type, not being 'VerifiableCredential'. + -- When credentials with 3 or more types appear, we could have to use a separate table for the types. + credential_type text, + constraint fk_discoveryservice_credential_presentation foreign key (presentation_id) references discoveryservice_presentations (id) on delete cascade +); + +-- discoveryservice_credential_props contains the credentialSubject properties of a credential in a presentation of the discovery service. +-- It is used by clients to search for presentations. +create table discoveryservice_credential_props +( + id text not null, + key text not null, + value text, + PRIMARY KEY (id, key), + -- cascading delete: if the presentation gets deleted, the properties get deleted as well + constraint fk_discoveryservice_credential_id foreign key (id) references discoveryservice_credentials (id) on delete cascade +); \ No newline at end of file diff --git a/storage/test.go b/storage/test.go index 34cc9a0ef7..299fc89faa 100644 --- a/storage/test.go +++ b/storage/test.go @@ -34,6 +34,7 @@ const SQLiteInMemoryConnectionString = "file::memory:?cache=shared" func NewTestStorageEngineInDir(dir string) Engine { result := New().(*engine) result.config.SQL = SQLConfig{ConnectionString: SQLiteInMemoryConnectionString} + //result.config.SQL = SQLConfig{ConnectionString: "file:../../data/sqlite.db"} _ = result.Configure(core.TestServerConfig(core.ServerConfig{Datadir: dir + "/data"})) return result } diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index fd4ec48ac8..5267a3b4f9 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -20,7 +20,10 @@ package credential import ( + "errors" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "strings" ) // FindValidator finds the Validator the provided credential based on its Type @@ -52,3 +55,48 @@ func ExtractTypes(credential vc.VerifiableCredential) []string { return vcTypes } + +func PresentationSigner(presentation vc.VerifiablePresentation) (*did.DID, error) { + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + token := presentation.JWT() + issuer := token.Issuer() + if issuer == "" { + return nil, errors.New("JWT presentation does not have 'iss' claim") + } + return did.ParseDID(issuer) + default: + return nil, errors.New("unsupported presentation format") + } +} + +func PresentationSigningKeyID(presentation vc.VerifiablePresentation) (*did.DIDURL, error) { + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + token := presentation.JWT() + keyID, exists := token.Get("kid") + if !exists { + return nil, errors.New("JWT presentation does not have 'kid' claim") + } + keyIDString, isString := keyID.(string) + if !isString { + return nil, errors.New("JWT presentation 'kid' claim is not a string") + } + issuer, err := PresentationSigner(presentation) + if err != nil { + return nil, err + } + if strings.HasPrefix(keyIDString, "#") { + // Key ID is a fragment, so it's a relative URL to the JWT issuer + keyIDString = issuer.String() + keyIDString + } else { + // Key ID is fully qualified, must be prefixed with JWT issuer + if !strings.HasPrefix(keyIDString, issuer.String()+"#") { + return nil, errors.New("JWT presentation 'kid' claim must be scoped to 'iss' claim if absolute") + } + } + return did.ParseDIDURL(keyIDString) + default: + return nil, errors.New("unsupported presentation format") + } +} diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 027f957fbf..71b3d6b8dc 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -292,8 +292,9 @@ func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential // 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) { + type Alias vc.VerifiableCredential // jsonpath works on interfaces, so convert the VC to an interface - asJSON, _ := json.Marshal(credential) + asJSON, _ := json.Marshal(Alias(credential)) var asInterface interface{} _ = json.Unmarshal(asJSON, &asInterface) diff --git a/vcr/pe/schema/v2/schema.go b/vcr/pe/schema/v2/schema.go index 44de55fdd8..8c3da1a2bc 100644 --- a/vcr/pe/schema/v2/schema.go +++ b/vcr/pe/schema/v2/schema.go @@ -51,19 +51,21 @@ var PresentationDefinition *jsonschema.Schema // PresentationSubmission is the JSON schema for a presentation submission. var PresentationSubmission *jsonschema.Schema +// Compiler is the JSON schema compiler. +var Compiler = jsonschema.NewCompiler() + func init() { // By default, it loads from filesystem, but that sounds unsafe. // Since register our schemas, we don't need to allow loading resources. loader.Load = func(url string) (io.ReadCloser, error) { return nil, fmt.Errorf("refusing to load unknown schema: %s", url) } - compiler := jsonschema.NewCompiler() - compiler.Draft = jsonschema.Draft7 - if err := loadSchemas(schemaFiles, compiler); err != nil { + Compiler.Draft = jsonschema.Draft7 + if err := loadSchemas(schemaFiles, Compiler); err != nil { panic(err) } - PresentationDefinition = compiler.MustCompile(presentationDefinition) - PresentationSubmission = compiler.MustCompile(presentationSubmission) + PresentationDefinition = Compiler.MustCompile(presentationDefinition) + PresentationSubmission = Compiler.MustCompile(presentationSubmission) } func loadSchemas(reader fs.ReadFileFS, compiler *jsonschema.Compiler) error { diff --git a/vcr/pe/test/definition_mapping.json b/vcr/pe/test/definition_mapping.json index b543faa577..5459a3ea65 100644 --- a/vcr/pe/test/definition_mapping.json +++ b/vcr/pe/test/definition_mapping.json @@ -20,6 +20,16 @@ "const": "NutsOrganizationCredential" } }, + { + "path": ["$.issuer"], + "filter": { + "type": "string", + "filter": { + "type": "string", + "pattern": "^did:example:123456789abcdefghi$" + } + } + }, { "path": ["$.credentialSubject.organization.name"], "filter": {