diff --git a/storage/sql_migrations/1_first.down.sql b/storage/sql_migrations/1_first.down.sql index e69de29bb2..8f7c41cf35 100644 --- a/storage/sql_migrations/1_first.down.sql +++ b/storage/sql_migrations/1_first.down.sql @@ -0,0 +1,2 @@ +drop table usecaselist_presentations; +drop table usecaselist_timestamps; \ No newline at end of file diff --git a/storage/sql_migrations/1_first.up.sql b/storage/sql_migrations/1_first.up.sql index e69de29bb2..1b21ea939b 100644 --- a/storage/sql_migrations/1_first.up.sql +++ b/storage/sql_migrations/1_first.up.sql @@ -0,0 +1,17 @@ +create table usecaselist_presentations +( + id text not null primary key, + list_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, + unique (list_id, credential_subject_id) +); + +create table usecaselist_timestamps +( + list_id text not null primary key, + timestamp integer not null +); \ No newline at end of file diff --git a/usecase/api/v1/api.go b/usecase/api/v1/api.go new file mode 100644 index 0000000000..30591b23a3 --- /dev/null +++ b/usecase/api/v1/api.go @@ -0,0 +1,11 @@ +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/usecase/config.go b/usecase/config.go new file mode 100644 index 0000000000..083c9b7629 --- /dev/null +++ b/usecase/config.go @@ -0,0 +1,48 @@ +/* + * 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 usecase + +// Config holds the config for use case listDefinition +type Config struct { + Maintainer MaintainerConfig `koanf:"maintainer"` + Definitions DefinitionsConfig `koanf:"definitions"` +} + +type DefinitionsConfig struct { + Directory string `koanf:"directory"` +} + +// MaintainerConfig holds the config for the maintainer +type MaintainerConfig struct { + // DefinitionIDs specifies which use case lists the maintainer serves. + DefinitionIDs []string `koanf:"definition_ids"` + // Directory is the directory where the maintainer stores the lists. + Directory string `koanf:"directory"` +} + +// DefaultConfig returns the default configuration. +func DefaultConfig() Config { + return Config{ + Maintainer: MaintainerConfig{}, + } +} + +func (c Config) IsMaintainer() bool { + return len(c.Maintainer.DefinitionIDs) > 0 +} diff --git a/usecase/definition.go b/usecase/definition.go new file mode 100644 index 0000000000..20bf8108ee --- /dev/null +++ b/usecase/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 usecase + +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() { + usecaseDefinitionSchemaData, err := jsonSchemaFiles.ReadFile("usecase-definition-schema.json") + if err != nil { + panic(err) + } + const schemaURL = "http://nuts.nl/schemas/usecase-v0.json" + if err := v2.Compiler.AddResource(schemaURL, bytes.NewReader(usecaseDefinitionSchemaData)); err != nil { + panic(err) + } + definitionJsonSchema = v2.Compiler.MustCompile(schemaURL) +} + +// Definition holds the definition of a use case list. +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/usecase/interface.go b/usecase/interface.go new file mode 100644 index 0000000000..e3b3d34248 --- /dev/null +++ b/usecase/interface.go @@ -0,0 +1,26 @@ +/* + * 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 usecase + +// 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 diff --git a/usecase/log/logger.go b/usecase/log/logger.go new file mode 100644 index 0000000000..2c236bcbcf --- /dev/null +++ b/usecase/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, "UseCase") + +// Logger returns a logger with the module field set +func Logger() *logrus.Entry { + return _logger +} diff --git a/usecase/maintainer.go b/usecase/maintainer.go new file mode 100644 index 0000000000..f3656ab9e2 --- /dev/null +++ b/usecase/maintainer.go @@ -0,0 +1,196 @@ +/* + * 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 usecase + +import ( + "errors" + "fmt" + "github.com/google/uuid" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/usecase/log" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "time" +) + +var ErrListNotFound = errors.New("list not found") +var ErrPresentationAlreadyExists = errors.New("presentation already exists") + +type usecaselistPresentation struct { + ID string `gorm:"primaryKey"` + ListID string + Timestamp uint64 + CredentialSubjectID string + PresentationID string + PresentationRaw string + PresentationExpiration int +} + +type usecaselistTimestamp struct { + ListID string `gorm:"primaryKey"` + Timestamp uint64 +} + +type maintainer struct { + db *gorm.DB + lists map[string]Definition +} + +func newMaintainer(db *gorm.DB, lists map[string]Definition) (*maintainer, error) { + result := &maintainer{ + db: db, + lists: lists, + } + // Initialize timestamp table + err := result.db.Transaction(func(tx *gorm.DB) error { + for listID, _ := range lists { + err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&usecaselistTimestamp{ListID: listID, Timestamp: 0}).Error + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("initialize timestamp table: %w", err) + } + return result, nil +} + +func (m *maintainer) add(useCaseID string, presentation vc.VerifiablePresentation) error { + if presentation.Format() != vc.JWTPresentationProofFormat { + return errors.New("only JWT presentations are supported") + } + definition, exists := m.lists[useCaseID] + if !exists { + return ErrListNotFound + } + // TODO: Verify VP + 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 maintainer 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 maintainer 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) + } + // TODO: Check credentials aren't valid longer than the presentation + credentialSubjectID, err := credential.PresentationSigner(presentation) + if err != nil { + return err + } + if exists, err := m.exists(useCaseID, credentialSubjectID.String(), presentation.ID.String()); err != nil { + return err + } else if exists { + return ErrPresentationAlreadyExists + } + if err := m.prune(); err != nil { + return err + } + + err = m.db.Transaction(func(tx *gorm.DB) error { + // Lock (SELECT FOR UPDATE) usecaselist_timestamps row to prevent concurrent updates to the same list, which could mess up the lamport timestamp. + var lamportTimestamp usecaselistTimestamp + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where(usecaselistPresentation{ListID: useCaseID}). + Find(&lamportTimestamp). + Error; err != nil { + return err + } + // Increment timestamp + lamportTimestamp.Timestamp++ + if err := tx.Save(&lamportTimestamp).Error; err != nil { + return err + } + // Now store the presentation itself + return tx.Create(&usecaselistPresentation{ + ID: uuid.NewString(), + ListID: useCaseID, + Timestamp: lamportTimestamp.Timestamp, + CredentialSubjectID: credentialSubjectID.String(), + PresentationID: presentation.ID.String(), + PresentationRaw: presentation.Raw(), + PresentationExpiration: int(expiration.Unix()), + }).Error + }) + if err != nil { + return fmt.Errorf("add list '%s' presentation '%s': %w", useCaseID, presentation.ID, err) + } + return nil +} + +func (m *maintainer) get(listID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) { + if _, exists := m.lists[listID]; !exists { + return nil, nil, ErrListNotFound + } + var rows []usecaselistPresentation + err := m.db.Order("timestamp ASC").Find(&rows, "list_id = ? AND timestamp > ?", listID, int(startAt)).Error + if err != nil { + return nil, nil, fmt.Errorf("get list '%s': %w", listID, err) + } + timestamp := startAt + results := make([]vc.VerifiablePresentation, 0, len(rows)) + for _, row := range rows { + presentation, err := vc.ParseVerifiablePresentation(row.PresentationRaw) + if err != nil { + return nil, nil, fmt.Errorf("get list '%s' invalid presentation '%s': %w", listID, row.PresentationID, err) + } + results = append(results, *presentation) + timestamp = Timestamp(row.Timestamp) + } + return results, ×tamp, nil +} + +func (m *maintainer) exists(listID string, credentialSubjectID string, presentationID string) (bool, error) { + var count int64 + if err := m.db.Model(usecaselistPresentation{}).Where(usecaselistPresentation{ + ListID: listID, + CredentialSubjectID: credentialSubjectID, + PresentationID: presentationID, + }).Count(&count).Error; err != nil { + return false, fmt.Errorf("check presentation existence: %w", err) + } + return count > 0, nil +} + +func (m *maintainer) prune() error { + num, err := m.removeExpired() + if err != nil { + return err + } + if num > 0 { + log.Logger().Debugf("Pruned %d expired presentations", num) + } + return nil +} + +func (m *maintainer) removeExpired() (int, error) { + result := m.db.Where("presentation_expiration < ?", time.Now().Unix()).Delete(usecaselistPresentation{}) + if result.Error != nil { + return 0, fmt.Errorf("prune presentations: %w", result.Error) + } + return int(result.RowsAffected), nil +} diff --git a/usecase/maintainer_test.go b/usecase/maintainer_test.go new file mode 100644 index 0000000000..a4fd4aa58c --- /dev/null +++ b/usecase/maintainer_test.go @@ -0,0 +1,330 @@ +/* + * 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 usecase + +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/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +var testDefinition = Definition{ + Endpoint: "http://example.com/usecase", + PresentationMaxValidity: int((24 * time.Hour).Seconds()), +} +var testUseCaseID = "usecase_v1" +var testDefinitions = map[string]Definition{ + testUseCaseID: testDefinition, + "other": testDefinition, +} +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 = createCredential(authorityDID, aliceDID) + vpAlice = createPresentation(aliceDID, vcAlice) + vcBob = createCredential(authorityDID, bobDID) + vpBob = createPresentation(bobDID, vcBob) +} + +func Test_maintainer_exists(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Run("empty list", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + exists, err := m.exists(testUseCaseID, 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 := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpBob)) + exists, err := m.exists(testUseCaseID, 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 := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpAlice)) + 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 := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpAlice)) + exists, err := m.exists(testUseCaseID, aliceDID.String(), vpAlice.ID.String()) + assert.NoError(t, err) + assert.True(t, exists) + }) +} + +func Test_maintainer_add(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Run("ok", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + + err := m.add(testUseCaseID, vpAlice) + assert.NoError(t, err) + + _, timestamp, err := m.get(testUseCaseID, 0) + require.NoError(t, err) + assert.Equal(t, Timestamp(1), *timestamp) + }) + t.Run("already exists", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + + err := m.add(testUseCaseID, vpAlice) + assert.NoError(t, err) + err = m.add(testUseCaseID, vpAlice) + assert.EqualError(t, err, "presentation already exists") + }) + t.Run("valid for too long", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + def := m.lists[testUseCaseID] + def.PresentationMaxValidity = 1 + m.lists[testUseCaseID] = def + + err := m.add(testUseCaseID, vpAlice) + assert.EqualError(t, err, "presentation is valid for too long (max 1s)") + }) + t.Run("not valid long enough", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + def := m.lists[testUseCaseID] + def.PresentationMaxValidity = int((24 * time.Hour).Seconds() * 365) + m.lists[testUseCaseID] = def + + err := m.add(testUseCaseID, 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 := setupMaintainer(t, storageEngine) + + vpWithoutID := createPresentationWithClaims(aliceDID, func(claims map[string]interface{}) { + delete(claims, "jti") + }, vcAlice) + err := m.add(testUseCaseID, vpWithoutID) + assert.EqualError(t, err, "presentation does not have an ID") + }) + t.Run("not a JWT", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + err := m.add(testUseCaseID, vc.VerifiablePresentation{}) + assert.EqualError(t, err, "only JWT presentations are supported") + }) + t.Run("list unknown", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + err := m.add("unknown", vpAlice) + assert.EqualError(t, err, "list not found") + }) +} + +func Test_maintainer_get(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("empty list, empty timestamp", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + presentations, timestamp, err := m.get(testUseCaseID, 0) + assert.NoError(t, err) + assert.Empty(t, presentations) + assert.Empty(t, timestamp) + }) + t.Run("1 entry, empty timestamp", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpAlice)) + presentations, timestamp, err := m.get(testUseCaseID, 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 := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpAlice)) + require.NoError(t, m.add(testUseCaseID, vpBob)) + presentations, timestamp, err := m.get(testUseCaseID, 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 := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpAlice)) + require.NoError(t, m.add(testUseCaseID, vpBob)) + presentations, timestamp, err := m.get(testUseCaseID, 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 := setupMaintainer(t, storageEngine) + require.NoError(t, m.add(testUseCaseID, vpAlice)) + require.NoError(t, m.add(testUseCaseID, vpBob)) + presentations, timestamp, err := m.get(testUseCaseID, 2) + assert.NoError(t, err) + assert.Equal(t, []vc.VerifiablePresentation{}, presentations) + assert.Equal(t, Timestamp(2), *timestamp) + }) + t.Run("list unknown", func(t *testing.T) { + m := setupMaintainer(t, storageEngine) + _, _, err := m.get("unknown", 0) + assert.EqualError(t, err, "list not found") + }) +} + +func setupMaintainer(t *testing.T, storageInstance storage.Engine) *maintainer { + t.Cleanup(func() { + underlyingDB, err := storageInstance.SQLDatabase().DB() + require.NoError(t, err) + _, err = underlyingDB.Exec("DELETE FROM usecaselist_presentations") + require.NoError(t, err) + _, err = underlyingDB.Exec("DELETE FROM usecaselist_timestamps") + require.NoError(t, err) + }) + // copy testDefinitions to make sure tests don't influence each other + testDefinitionsCopy := make(map[string]Definition) + for k, v := range testDefinitions { + testDefinitionsCopy[k] = v + } + m, err := newMaintainer(storageInstance.SQLDatabase(), testDefinitionsCopy) + require.NoError(t, err) + return m +} + +func createCredential(issuerDID did.DID, subjectDID did.DID) vc.VerifiableCredential { + vcID := issuerDID + vcID.Fragment = uuid.NewString() + vcIDURI := vcID.URI() + expirationDate := time.Now().Add(time.Hour) + 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) { + return signJWT(subjectDID, claims, headers) + }) + if err != nil { + panic(err) + } + return *result +} + +func createPresentation(subjectDID did.DID, credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { + return createPresentationWithClaims(subjectDID, func(claims map[string]interface{}) { + // do nothing + }, credentials...) +} + +func createPresentationWithClaims(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.WithoutURL().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 := 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/usecase/module.go b/usecase/module.go new file mode 100644 index 0000000000..a81e7a2272 --- /dev/null +++ b/usecase/module.go @@ -0,0 +1,135 @@ +/* + * 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 usecase + +import ( + "errors" + "fmt" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/storage" + "os" + "path" + "strings" +) + +const ModuleName = "Usecase" + +var _ core.Injectable = &Module{} +var _ core.Runnable = &Module{} +var _ core.Configurable = &Module{} +var ErrMaintainerModeDisabled = errors.New("server is not a use case list maintainer") + +func New(storageInstance storage.Engine) *Module { + return &Module{ + storageInstance: storageInstance, + } +} + +type Module struct { + config Config + storageInstance storage.Engine + maintainer *maintainer + definitions map[string]Definition +} + +func (m *Module) Configure(_ core.ServerConfig) error { + if m.config.Definitions.Directory == "" { + return nil + } + var err error + m.definitions, err = loadDefinitions(m.config.Definitions.Directory) + if err != nil { + return err + } + return nil +} + +func (m *Module) Start() error { + if len(m.config.Maintainer.DefinitionIDs) > 0 { + // Get the definitions that are enabled for this maintainer + maintainedDefinitions := make(map[string]Definition) + for _, definitionID := range m.config.Maintainer.DefinitionIDs { + if definition, exists := m.definitions[definitionID]; !exists { + return fmt.Errorf("definition '%s' not found", definitionID) + } else { + maintainedDefinitions[definitionID] = definition + } + } + var err error + m.maintainer, err = newMaintainer(m.storageInstance.SQLDatabase(), maintainedDefinitions) + if err != nil { + return fmt.Errorf("unable to start maintainer: %w", err) + } + } + 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(listID string, presentation vc.VerifiablePresentation) error { + if m.maintainer == nil { + return ErrMaintainerModeDisabled + } + return m.maintainer.add(listID, presentation) +} + +func (m *Module) Get(listID string, startAt Timestamp) ([]vc.VerifiablePresentation, *Timestamp, error) { + if m.maintainer == nil { + return nil, nil, ErrMaintainerModeDisabled + } + return m.maintainer.get(listID, 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/usecase/module_test.go b/usecase/module_test.go new file mode 100644 index 0000000000..eada85b7a9 --- /dev/null +++ b/usecase/module_test.go @@ -0,0 +1,77 @@ +package usecase + +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" +) + +func TestModule_Maintainer(t *testing.T) { + const listID = "urn:nuts.nl:usecase:eOverdrachtDev2023" + t.Run("lifecycle", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + module := New(storageEngine) + module.config = DefaultConfig() + module.config.Definitions.Directory = "./test/valid" + module.config.Maintainer.DefinitionIDs = []string{listID} + require.NoError(t, module.Configure(*core.NewServerConfig())) + require.NoError(t, module.Start()) + + // Empty at first + presentations, timestamp, err := module.Get(listID, 0) + require.NoError(t, err) + require.Equal(t, Timestamp(0), *timestamp) + require.Empty(t, presentations) + + // Add a presentation + require.NoError(t, module.Add(listID, vpAlice)) + presentations, timestamp, err = module.Get(listID, 0) + require.NoError(t, err) + require.Equal(t, Timestamp(1), *timestamp) + require.Equal(t, []vc.VerifiablePresentation{vpAlice}, presentations) + }) + t.Run("add - not a maintainer", func(t *testing.T) { + err := (&Module{}).Add(listID, vpAlice) + require.Equal(t, err, ErrMaintainerModeDisabled) + }) + t.Run("get - not a maintainer", func(t *testing.T) { + _, _, err := (&Module{}).Get(listID, 0) + require.Equal(t, err, ErrMaintainerModeDisabled) + }) +} + +func TestModule_Shutdown(t *testing.T) { + assert.NoError(t, (&Module{}).Shutdown()) +} + +func TestModule_Name(t *testing.T) { + assert.Equal(t, "Usecase", (&Module{}).Name()) +} + +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/usecase/test/duplicate_id/1.json b/usecase/test/duplicate_id/1.json new file mode 100644 index 0000000000..533dc08feb --- /dev/null +++ b/usecase/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/usecase/test/duplicate_id/2.json b/usecase/test/duplicate_id/2.json new file mode 100644 index 0000000000..533dc08feb --- /dev/null +++ b/usecase/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/usecase/test/duplicate_id/README.md b/usecase/test/duplicate_id/README.md new file mode 100644 index 0000000000..f0fc1802ed --- /dev/null +++ b/usecase/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/usecase/test/invalid_definition/1.json b/usecase/test/invalid_definition/1.json new file mode 100644 index 0000000000..0db3279e44 --- /dev/null +++ b/usecase/test/invalid_definition/1.json @@ -0,0 +1,3 @@ +{ + +} diff --git a/usecase/test/invalid_definition/README.md b/usecase/test/invalid_definition/README.md new file mode 100644 index 0000000000..6053c6729b --- /dev/null +++ b/usecase/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/usecase/test/invalid_json/1.json b/usecase/test/invalid_json/1.json new file mode 100644 index 0000000000..7e31dc3cad --- /dev/null +++ b/usecase/test/invalid_json/1.json @@ -0,0 +1 @@ +this is not JSON \ No newline at end of file diff --git a/usecase/test/invalid_json/README.md b/usecase/test/invalid_json/README.md new file mode 100644 index 0000000000..30610e3784 --- /dev/null +++ b/usecase/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/usecase/test/valid/eoverdracht.json b/usecase/test/valid/eoverdracht.json new file mode 100644 index 0000000000..533dc08feb --- /dev/null +++ b/usecase/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/usecase/test/valid/subdir/README.md b/usecase/test/valid/subdir/README.md new file mode 100644 index 0000000000..b1778a548c --- /dev/null +++ b/usecase/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/usecase/test/valid/subdir/empty.json b/usecase/test/valid/subdir/empty.json new file mode 100644 index 0000000000..2c63c08510 --- /dev/null +++ b/usecase/test/valid/subdir/empty.json @@ -0,0 +1,2 @@ +{ +} diff --git a/usecase/usecase-definition-schema.json b/usecase/usecase-definition-schema.json new file mode 100644 index 0000000000..41484e9ba4 --- /dev/null +++ b/usecase/usecase-definition-schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Use Case 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/vcr/credential/resolver.go b/vcr/credential/resolver.go index fd4ec48ac8..7557ddc65c 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.DID, 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/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": {