From 2d077d499c274e7d19f6b14dd9b51e5f8731e108 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Wed, 18 Sep 2024 10:19:28 +0200 Subject: [PATCH] add DIDMethods config param to discovery service (#3384) --- discovery/api/server/api.go | 2 ++ discovery/client.go | 45 +++++++++++++++++++++++++++-- discovery/client_test.go | 41 ++++++++++++++++++++++++++ discovery/definition.go | 3 ++ discovery/interface.go | 2 ++ discovery/module.go | 7 ++++- discovery/module_test.go | 20 +++++++++++-- discovery/test.go | 12 ++++++-- docs/pages/deployment/discovery.rst | 1 + 9 files changed, 125 insertions(+), 8 deletions(-) diff --git a/discovery/api/server/api.go b/discovery/api/server/api.go index 187fe67b4d..dd519d4439 100644 --- a/discovery/api/server/api.go +++ b/discovery/api/server/api.go @@ -43,6 +43,8 @@ func (w *Wrapper) ResolveStatusCode(err error) int { switch { case errors.Is(err, discovery.ErrInvalidPresentation): return http.StatusBadRequest + case errors.Is(err, discovery.ErrDIDMethodsNotSupported): + return http.StatusBadRequest case errors.Is(err, discovery.ErrServiceNotFound): return http.StatusNotFound default: diff --git a/discovery/client.go b/discovery/client.go index 3d67d47b34..197acc4482 100644 --- a/discovery/client.go +++ b/discovery/client.go @@ -34,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "slices" "strings" "time" ) @@ -76,6 +77,21 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service if err != nil { return err } + // filter DIDs on DID methods supported by the service + if len(service.DIDMethods) > 0 { + j := 0 + for i, did := range subjectDIDs { + if slices.Contains(service.DIDMethods, did.Method) { + subjectDIDs[j] = subjectDIDs[i] + j++ + } + } + subjectDIDs = subjectDIDs[:j] + } + if len(subjectDIDs) == 0 { + return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID) + } + var asSoonAsPossible time.Time if err := r.store.updatePresentationRefreshTime(serviceID, subjectID, parameters, &asSoonAsPossible); err != nil { return err @@ -120,6 +136,10 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service } func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, serviceID, subjectID string) error { + service, serviceExists := r.services[serviceID] + if !serviceExists { + return ErrServiceNotFound + } // delete DID/service combination from DB, so it won't be registered again err := r.store.updatePresentationRefreshTime(serviceID, subjectID, nil, nil) if err != nil { @@ -133,6 +153,22 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi return err } + // filter DIDs on DID methods supported by the service + if len(service.DIDMethods) > 0 { + j := 0 + for i, did := range subjectDIDs { + if slices.Contains(service.DIDMethods, did.Method) { + subjectDIDs[j] = subjectDIDs[i] + j++ + } + } + subjectDIDs = subjectDIDs[:j] + } + if len(subjectDIDs) == 0 { + // if this means we can't deactivate a previously registered subject because the DID methods have changed, then we rely on the refresh interval to clean up. + return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID) + } + // find all active presentations vps2D, err := r.store.getSubjectVPsOnService(serviceID, subjectDIDs) if err != nil { @@ -141,7 +177,6 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi // retract active registrations for all DIDs // failures are collected and merged into a single error - service := r.services[serviceID] var loopErrs []error for did, vps := range vps2D { for _, vp := range vps { @@ -232,7 +267,13 @@ func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time for _, candidate := range refreshCandidates { if err = r.activate(ctx, candidate.ServiceID, candidate.SubjectID, candidate.Parameters); err != nil { var loopErr error - if errors.Is(err, didsubject.ErrSubjectNotFound) { + if errors.Is(err, ErrDIDMethodsNotSupported) { + // DID method no longer supported, remove + err = r.store.updatePresentationRefreshTime(candidate.ServiceID, candidate.SubjectID, nil, nil) + if err != nil { + loopErr = fmt.Errorf("failed to remove subject with unsupported DID method (service=%s, subject=%s): %w", candidate.ServiceID, candidate.SubjectID, err) + } + } else if errors.Is(err, didsubject.ErrSubjectNotFound) { // Subject has probably been deactivated. Remove from service or registration will be retried every refresh interval. err = r.store.updatePresentationRefreshTime(candidate.ServiceID, candidate.SubjectID, candidate.Parameters, nil) if err != nil { diff --git a/discovery/client_test.go b/discovery/client_test.go index 7d9e878466..7e746dce22 100644 --- a/discovery/client_test.go +++ b/discovery/client_test.go @@ -89,6 +89,16 @@ func Test_defaultClientRegistrationManager_activate(t *testing.T) { require.ErrorIs(t, err, ErrPresentationRegistrationFailed) assert.ErrorContains(t, err, "invoker error") }) + t.Run("DID method not supported", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockSubjectManager := didsubject.NewMockSubjectManager(ctrl) + mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + manager := newRegistrationManager(testDefinitions(), nil, nil, nil, mockSubjectManager) + + err := manager.activate(audit.TestContext(), unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) + + assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + }) t.Run("no matching credentials", func(t *testing.T) { ctrl := gomock.NewController(t) invoker := client.NewMockHTTPClient(ctrl) @@ -239,6 +249,17 @@ func Test_defaultClientRegistrationManager_deactivate(t *testing.T) { assert.NoError(t, err) }) + t.Run("DID method not supported", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockSubjectManager := didsubject.NewMockSubjectManager(ctrl) + mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, nil, nil, mockSubjectManager) + + err := manager.deactivate(audit.TestContext(), unsupportedServiceID, aliceSubject) + + assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + }) t.Run("deregistering from Discovery Service fails", func(t *testing.T) { ctrl := gomock.NewController(t) invoker := client.NewMockHTTPClient(ctrl) @@ -349,6 +370,25 @@ func Test_defaultClientRegistrationManager_refresh(t *testing.T) { assert.EqualError(t, err, "removed unknown subject (service=usecase_v1, subject=alice)") }) + + t.Run("deactivate unsupported DID method", func(t *testing.T) { + store := setupStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + mockSubjectManager := didsubject.NewMockSubjectManager(ctrl) + mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + _ = store.updatePresentationRefreshTime(unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &time.Time{}) + + err := manager.refresh(audit.TestContext(), time.Now()) + + // refresh clears the registration + require.NoError(t, err) + refreshTime, err := store.getPresentationRefreshTime(unsupportedServiceID, aliceSubject) + assert.NoError(t, err) + assert.Nil(t, refreshTime) + }) } func Test_clientUpdater_updateService(t *testing.T) { @@ -432,6 +472,7 @@ func Test_clientUpdater_update(t *testing.T) { httpClient := client.NewMockHTTPClient(ctrl) httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil) httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, 0, errors.New("test")) + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil) updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) err := updater.update(context.Background()) diff --git a/discovery/definition.go b/discovery/definition.go index 6315424b65..fe84531bab 100644 --- a/discovery/definition.go +++ b/discovery/definition.go @@ -48,6 +48,9 @@ func init() { type ServiceDefinition struct { // ID is the unique identifier of the use case. ID string `json:"id"` + // DIDMethods is a list of DID methods that are supported by the use case. + // If empty, all methods are supported. + DIDMethods []string `json:"did_methods"` // Endpoint is the endpoint where the use case list is served. Endpoint string `json:"endpoint"` // PresentationDefinition specifies the Presentation ServiceDefinition submissions to the list must conform to, diff --git a/discovery/interface.go b/discovery/interface.go index 21e776705f..3435340af6 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -34,6 +34,8 @@ var ErrPresentationAlreadyExists = errors.New("presentation already exists") // ErrPresentationRegistrationFailed indicates registration of a presentation on a remote Discovery Service failed. var ErrPresentationRegistrationFailed = errors.New("registration of Verifiable Presentation on remote Discovery Service failed") +var ErrDIDMethodsNotSupported = errors.New("DID methods not supported") + // authServerURLField is the field name for the authServerURL in the DiscoveryRegistrationCredential. // it is used to resolve authorization server metadata and thus the endpoints for a service entry. const authServerURLField = "authServerURL" diff --git a/discovery/module.go b/discovery/module.go index e8dcf27a8b..268b958955 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -216,11 +216,16 @@ func (m *Module) verifyRegistration(definition ServiceDefinition, presentation v if time.Until(expiration) > time.Duration(definition.PresentationMaxValidity)*time.Second { return errors.Join(ErrInvalidPresentation, fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second)) } - // Check if the presentation already exists credentialSubjectID, err := credential.PresentationSigner(presentation) if err != nil { return err } + // Check if the issuer uses a supported DID method + if len(definition.DIDMethods) > 0 && !slices.Contains(definition.DIDMethods, credentialSubjectID.Method) { + return errors.Join(ErrInvalidPresentation, ErrDIDMethodsNotSupported) + } + + // Check if the presentation already exists exists, err := m.store.exists(definition.ID, credentialSubjectID.String(), presentation.ID.String()) if err != nil { return err diff --git a/discovery/module_test.go b/discovery/module_test.go index 258db8228a..19ba6ec7b8 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -153,8 +153,7 @@ func Test_Module_Register(t *testing.T) { t.Run("not conform to Presentation Definition", func(t *testing.T) { m, _ := setupModule(t, storageEngine) - // Presentation Definition only allows did:example DIDs - otherVP := createPresentationCustom(unsupportedDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + otherVP := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { claims[jwt.AudienceKey] = []string{testServiceID} }, createCredential(unsupportedDID, unsupportedDID, nil, nil)) err := m.Register(ctx, testServiceID, otherVP) @@ -163,6 +162,20 @@ func Test_Module_Register(t *testing.T) { _, timestamp, _ := m.Get(ctx, testServiceID, 0) assert.Equal(t, 0, timestamp) }) + t.Run("unsupported DID method", func(t *testing.T) { + m, _ := setupModule(t, storageEngine, func(module *Module) { + module.serverDefinitions[unsupportedServiceID] = ServiceDefinition{ + ID: unsupportedServiceID, + DIDMethods: []string{"unsupported"}, + } + }) + otherVP := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { + claims[jwt.AudienceKey] = []string{unsupportedServiceID} + }, vcAlice, aliceDiscoveryCredential) + + err := m.Register(ctx, unsupportedServiceID, otherVP) + assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + }) t.Run("cycle detected", func(t *testing.T) { m, _ := setupModule(t, storageEngine, func(module *Module) { module.allDefinitions["someother"] = ServiceDefinition{ @@ -312,6 +325,7 @@ func setupModule(t *testing.T, storageInstance storage.Engine, visitors ...func( httpClient := client.NewMockHTTPClient(ctrl) httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, 0, nil).AnyTimes() httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(nil, 0, nil).AnyTimes() + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(nil, 0, nil).AnyTimes() m.httpClient = httpClient m.allDefinitions = testDefinitions() m.serverDefinitions = map[string]ServiceDefinition{ @@ -542,7 +556,7 @@ func TestModule_Services(t *testing.T) { services := (&Module{ allDefinitions: testDefinitions(), }).Services() - assert.Len(t, services, 2) + assert.Len(t, services, 3) }) } diff --git a/discovery/test.go b/discovery/test.go index db5ad1e477..b0db6e2551 100644 --- a/discovery/test.go +++ b/discovery/test.go @@ -54,12 +54,14 @@ var vpBob vc.VerifiablePresentation var unsupportedDID did.DID var testServiceID = "usecase_v1" +var unsupportedServiceID = "unsupported" func testDefinitions() map[string]ServiceDefinition { return map[string]ServiceDefinition{ testServiceID: { - ID: testServiceID, - Endpoint: "http://example.com/usecase", + ID: testServiceID, + DIDMethods: []string{"example"}, + Endpoint: "http://example.com/usecase", PresentationDefinition: pe.PresentationDefinition{ Format: &pe.PresentationDefinitionClaimFormatDesignations{ "ldp_vc": { @@ -146,6 +148,12 @@ func testDefinitions() map[string]ServiceDefinition { }, PresentationMaxValidity: int((24 * time.Hour).Seconds()), }, + unsupportedServiceID: { + ID: "unsupported", + DIDMethods: []string{"unsupported"}, + Endpoint: "http://example.com/unsupported", + PresentationMaxValidity: int((24 * time.Hour).Seconds()), + }, } } diff --git a/docs/pages/deployment/discovery.rst b/docs/pages/deployment/discovery.rst index 9b4dffb476..5047f41d57 100644 --- a/docs/pages/deployment/discovery.rst +++ b/docs/pages/deployment/discovery.rst @@ -12,6 +12,7 @@ The parties implementing that use case then configure their Nuts nodes with the The service definition is a JSON document agreed upon (and loaded) by all parties that specifies: - which Verifiable Credentials are required for the service, +- which DID methods are allowed (blank for all), - where the Discovery Service is hosted, and - how often the Verifiable Presentations must be updated.