diff --git a/README.rst b/README.rst index 26504b91ac..09145205e8 100644 --- a/README.rst +++ b/README.rst @@ -176,95 +176,95 @@ The following options can be configured on the server: :widths: 20 30 50 :class: options-table - ==================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ - Key Default Description - ==================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ - configfile nuts.yaml Nuts config file - cpuprofile When set, a CPU profile is written to the given path. Ignored when strictmode is set. - datadir ./data Directory where the node stores its files. - internalratelimiter true When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. - loggerformat text Log format (text, json) - strictmode true When set, insecure settings are forbidden. - url Public facing URL of the server (required). Must be HTTPS when strictmode is set. - verbosity info Log level (trace, debug, info, warn, error) - httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. - tls.certfile PEM file containing the certificate for the server (also used as client certificate). - tls.certheader Name of the HTTP header that will contain the client certificate when TLS is offloaded. - tls.certkeyfile PEM file containing the private key of the server certificate. - tls.offload Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. - tls.truststorefile truststore.pem PEM file containing the trusted CA certificates for authenticating remote servers. + ============================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ + Key Default Description + ============================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ + configfile nuts.yaml Nuts config file + cpuprofile When set, a CPU profile is written to the given path. Ignored when strictmode is set. + datadir ./data Directory where the node stores its files. + internalratelimiter true When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. + loggerformat text Log format (text, json) + strictmode true When set, insecure settings are forbidden. + url Public facing URL of the server (required). Must be HTTPS when strictmode is set. + verbosity info Log level (trace, debug, info, warn, error) + httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + tls.certfile PEM file containing the certificate for the server (also used as client certificate). + tls.certheader Name of the HTTP header that will contain the client certificate when TLS is offloaded. + tls.certkeyfile PEM file containing the private key of the server certificate. + tls.offload Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. + tls.truststorefile truststore.pem PEM file containing the trusted CA certificates for authenticating remote servers. **Auth** - auth.accesstokenlifespan 60 defines how long (in seconds) an access token is valid. Uses default in strict mode. - auth.clockskew 5000 allowed JWT Clock skew in milliseconds - auth.contractvalidators [irma,uzi,dummy,employeeid] sets the different contract validators to use - auth.http.timeout 30 HTTP timeout (in seconds) used by the Auth API HTTP client - auth.irma.autoupdateschemas true set if you want automatically update the IRMA schemas every 60 minutes. - auth.irma.schememanager pbdf IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. + auth.accesstokenlifespan 60 defines how long (in seconds) an access token is valid. Uses default in strict mode. + auth.clockskew 5000 allowed JWT Clock skew in milliseconds + auth.contractvalidators [irma,uzi,dummy,employeeid] sets the different contract validators to use + auth.irma.autoupdateschemas true set if you want automatically update the IRMA schemas every 60 minutes. + auth.irma.schememanager pbdf IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. **Crypto** - crypto.storage fs Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). - crypto.external.address Address of the external storage service. - crypto.external.timeout 100ms Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). - crypto.vault.address The Vault address. If set it overwrites the VAULT_ADDR env var. - crypto.vault.pathprefix kv The Vault path prefix. - crypto.vault.timeout 5s Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). - crypto.vault.token The Vault token. If set it overwrites the VAULT_TOKEN env var. + crypto.storage fs Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). + crypto.external.address Address of the external storage service. + crypto.external.timeout 100ms Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). + crypto.vault.address The Vault address. If set it overwrites the VAULT_ADDR env var. + crypto.vault.pathprefix kv The Vault path prefix. + crypto.vault.timeout 5s Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). + crypto.vault.token The Vault token. If set it overwrites the VAULT_TOKEN env var. **Discovery** - discovery.definitions.directory Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. - discovery.server.definition_ids [] IDs of the Discovery Service Definitions for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. + discovery.client.registration_refresh_interval 10m0s Interval at which the client should refresh checks for registrations to refresh on the configured Discovery Services,in Golang time.Duration string format (e.g. 1s). Note that it only will actually refresh registrations that about to expire (less than 1/4th of their lifetime left). + discovery.definitions.directory Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. + discovery.server.definition_ids [] IDs of the Discovery Service Definitions for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. **Events** - events.nats.hostname 0.0.0.0 Hostname for the NATS server - events.nats.port 4222 Port where the NATS server listens on - events.nats.storagedir Directory where file-backed streams are stored in the NATS server - events.nats.timeout 30 Timeout for NATS server operations + events.nats.hostname 0.0.0.0 Hostname for the NATS server + events.nats.port 4222 Port where the NATS server listens on + events.nats.storagedir Directory where file-backed streams are stored in the NATS server + events.nats.timeout 30 Timeout for NATS server operations **GoldenHammer** - goldenhammer.enabled true Whether to enable automatically fixing DID documents with the required endpoints. - goldenhammer.interval 10m0s The interval in which to check for DID documents to fix. + goldenhammer.enabled true Whether to enable automatically fixing DID documents with the required endpoints. + goldenhammer.interval 10m0s The interval in which to check for DID documents to fix. **HTTP** - http.default.address \:1323 Address and port the server will be listening to - http.default.log metadata 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). - http.default.tls Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', - http.default.auth.audience Expected audience for JWT tokens (default: hostname) - http.default.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers - 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. + http.default.address \:1323 Address and port the server will be listening to + http.default.log metadata 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). + http.default.tls Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', + http.default.auth.audience Expected audience for JWT tokens (default: hostname) + http.default.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers + 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,https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.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,https://w3id.org/vc/status-list/2021/v1] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. + jsonld.contexts.localmapping [https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.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,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.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json,https://w3id.org/vc/status-list/2021/v1] 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. - network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). - network.enablediscovery true Whether to enable automatic connecting to other nodes. - network.enabletls true Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. - network.grpcaddr \:5555 Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). - network.maxbackoff 24h0m0s Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). - network.nodedid Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. - network.protocols [] Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. - network.v2.diagnosticsinterval 5000 Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). - network.v2.gossipinterval 5000 Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. + network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. + network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). + network.enablediscovery true Whether to enable automatic connecting to other nodes. + network.enabletls true Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. + network.grpcaddr \:5555 Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). + network.maxbackoff 24h0m0s Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). + network.nodedid Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. + network.protocols [] Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. + network.v2.diagnosticsinterval 5000 Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). + network.v2.gossipinterval 5000 Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. **PKI** - pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail - pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true + pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail + pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true **Storage** - storage.bbolt.backup.directory Target directory for BBolt database backups. - storage.bbolt.backup.interval 0s Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. - storage.redis.address Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. - storage.redis.database Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. - storage.redis.password Redis database password. If set, it overrides the username in the connection URL. - storage.redis.username Redis database username. If set, it overrides the username in the connection URL. - storage.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. - storage.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. - storage.redis.sentinel.password Password for authenticating to Redis Sentinels. - storage.redis.sentinel.username Username for authenticating to Redis Sentinels. - storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). - storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + storage.bbolt.backup.directory Target directory for BBolt database backups. + storage.bbolt.backup.interval 0s Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. + storage.redis.address Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. + storage.redis.database Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. + storage.redis.password Redis database password. If set, it overrides the username in the connection URL. + storage.redis.username Redis database username. If set, it overrides the username in the connection URL. + storage.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. + storage.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. + storage.redis.sentinel.password Password for authenticating to Redis Sentinels. + storage.redis.sentinel.username Username for authenticating to Redis Sentinels. + storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). **VCR** - vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). - vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. - vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. + vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). + vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. + vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. **policy** - policy.address The address of a remote policy server. Mutual exclusive with policy.directory. - policy.directory Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. - ==================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ + policy.address The address of a remote policy server. Mutual exclusive with policy.directory. + policy.directory Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. + ============================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ This table is automatically generated using the configuration flags in the core and engines. When they're changed the options table must be regenerated using the Makefile: diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index eb46a661b8..f7badfa4a8 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -119,7 +119,7 @@ type RequestAccessTokenJSONBody struct { // RedirectURL The URL to which the user-agent will be redirected after the authorization request. RedirectURL *string `json:"redirectURL,omitempty"` - // Scope The scope that will be The service for which this access token can be used. + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` // UserID The ID of the user for which this access token is requested. diff --git a/cmd/root.go b/cmd/root.go index 6700da48c1..34d17ca548 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -196,7 +196,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { vdrInstance := vdr.NewVDR(cryptoInstance, networkInstance, didStore, eventManager, storageInstance) credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) - discoveryInstance := discovery.New(storageInstance, credentialInstance) + discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance) authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance) statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() @@ -224,7 +224,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance, policyInstance)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) - system.RegisterRoutes(&discoveryAPI.Wrapper{Server: discoveryInstance}) + system.RegisterRoutes(&discoveryAPI.Wrapper{Server: discoveryInstance, Client: discoveryInstance}) // Register engines // without dependencies diff --git a/discovery/api/v1/wrapper.go b/discovery/api/v1/api.go similarity index 66% rename from discovery/api/v1/wrapper.go rename to discovery/api/v1/api.go index 73edc41213..023ce014d7 100644 --- a/discovery/api/v1/wrapper.go +++ b/discovery/api/v1/api.go @@ -22,6 +22,8 @@ import ( "context" "errors" "github.com/labstack/echo/v4" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/discovery" "net/http" @@ -56,6 +58,9 @@ func (w *Wrapper) Routes(router core.EchoRouter) { return f(ctx, request) } }, + func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { + return audit.StrictMiddleware(f, discovery.ModuleName, operationID) + }, })) } @@ -77,7 +82,7 @@ func (w *Wrapper) GetPresentations(_ context.Context, request GetPresentationsRe } func (w *Wrapper) RegisterPresentation(_ context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) { - err := w.Server.Add(request.ServiceID, *request.Body) + err := w.Server.Register(request.ServiceID, *request.Body) if err != nil { return nil, err } @@ -99,3 +104,40 @@ func (w *Wrapper) SearchPresentations(_ context.Context, request SearchPresentat } return SearchPresentations200JSONResponse(result), nil } + +func (w *Wrapper) ActivateServiceForDID(ctx context.Context, request ActivateServiceForDIDRequestObject) (ActivateServiceForDIDResponseObject, error) { + subjectDID, err := did.ParseDID(request.Did) + if err != nil { + return nil, err + } + err = w.Client.ActivateServiceForDID(ctx, request.ServiceID, *subjectDID) + if errors.Is(err, discovery.ErrPresentationRegistrationFailed) { + // registration failed, but will be retried + return ActivateServiceForDID202JSONResponse{ + Reason: err.Error(), + }, nil + } + if err != nil { + // other error + return nil, err + } + return ActivateServiceForDID200Response{}, nil +} + +func (w *Wrapper) DeactivateServiceForDID(ctx context.Context, request DeactivateServiceForDIDRequestObject) (DeactivateServiceForDIDResponseObject, error) { + subjectDID, err := did.ParseDID(request.Did) + if err != nil { + return nil, err + } + err = w.Client.DeactivateServiceForDID(ctx, request.ServiceID, *subjectDID) + if errors.Is(err, discovery.ErrPresentationRegistrationFailed) { + // deactivation succeeded, but Verifiable Presentation couldn't be removed from remote Discovery Server. + return DeactivateServiceForDID202JSONResponse{ + Reason: err.Error(), + }, nil + } + if err != nil { + return nil, err + } + return DeactivateServiceForDID200Response{}, nil +} diff --git a/discovery/api/v1/wrapper_test.go b/discovery/api/v1/api_test.go similarity index 68% rename from discovery/api/v1/wrapper_test.go rename to discovery/api/v1/api_test.go index 0888e9c81a..a4b0cdf19d 100644 --- a/discovery/api/v1/wrapper_test.go +++ b/discovery/api/v1/api_test.go @@ -21,6 +21,7 @@ package v1 import ( "errors" 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/discovery" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -80,7 +81,7 @@ func TestWrapper_RegisterPresentation(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newMockContext(t) presentation := vc.VerifiablePresentation{} - test.server.EXPECT().Add(serviceID, presentation).Return(nil) + test.server.EXPECT().Register(serviceID, presentation).Return(nil) response, err := test.wrapper.RegisterPresentation(nil, RegisterPresentationRequestObject{ ServiceID: serviceID, @@ -93,7 +94,7 @@ func TestWrapper_RegisterPresentation(t *testing.T) { t.Run("error", func(t *testing.T) { test := newMockContext(t) presentation := vc.VerifiablePresentation{} - test.server.EXPECT().Add(serviceID, presentation).Return(discovery.ErrInvalidPresentation) + test.server.EXPECT().Register(serviceID, presentation).Return(discovery.ErrInvalidPresentation) _, err := test.wrapper.RegisterPresentation(nil, RegisterPresentationRequestObject{ ServiceID: serviceID, @@ -104,6 +105,75 @@ func TestWrapper_RegisterPresentation(t *testing.T) { }) } +func TestWrapper_ActivateServiceForDID(t *testing.T) { + t.Run("ok", func(t *testing.T) { + test := newMockContext(t) + expectedDID := "did:web:example.com" + test.client.EXPECT().ActivateServiceForDID(gomock.Any(), serviceID, did.MustParseDID(expectedDID)).Return(nil) + + response, err := test.wrapper.ActivateServiceForDID(nil, ActivateServiceForDIDRequestObject{ + ServiceID: serviceID, + Did: expectedDID, + }) + + assert.NoError(t, err) + assert.IsType(t, ActivateServiceForDID200Response{}, response) + }) + t.Run("ok, but registration failed", func(t *testing.T) { + test := newMockContext(t) + expectedDID := "did:web:example.com" + test.client.EXPECT().ActivateServiceForDID(gomock.Any(), gomock.Any(), gomock.Any()).Return(discovery.ErrPresentationRegistrationFailed) + + response, err := test.wrapper.ActivateServiceForDID(nil, ActivateServiceForDIDRequestObject{ + ServiceID: serviceID, + Did: expectedDID, + }) + + assert.NoError(t, err) + assert.IsType(t, ActivateServiceForDID202JSONResponse{}, response) + }) + t.Run("other error", func(t *testing.T) { + test := newMockContext(t) + expectedDID := "did:web:example.com" + test.client.EXPECT().ActivateServiceForDID(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("foo")) + + _, err := test.wrapper.ActivateServiceForDID(nil, ActivateServiceForDIDRequestObject{ + ServiceID: serviceID, + Did: expectedDID, + }) + + assert.Error(t, err) + }) +} + +func TestWrapper_DeactivateServiceForDID(t *testing.T) { + t.Run("ok", func(t *testing.T) { + test := newMockContext(t) + expectedDID := "did:web:example.com" + test.client.EXPECT().DeactivateServiceForDID(gomock.Any(), serviceID, did.MustParseDID(expectedDID)).Return(nil) + + response, err := test.wrapper.DeactivateServiceForDID(nil, DeactivateServiceForDIDRequestObject{ + ServiceID: serviceID, + Did: expectedDID, + }) + + assert.NoError(t, err) + assert.IsType(t, DeactivateServiceForDID200Response{}, response) + }) + t.Run("error", func(t *testing.T) { + test := newMockContext(t) + expectedDID := "did:web:example.com" + test.client.EXPECT().DeactivateServiceForDID(gomock.Any(), serviceID, did.MustParseDID(expectedDID)).Return(errors.New("foo")) + + _, err := test.wrapper.DeactivateServiceForDID(nil, DeactivateServiceForDIDRequestObject{ + ServiceID: serviceID, + Did: expectedDID, + }) + + assert.Error(t, err) + }) +} + func TestWrapper_ResolveStatusCode(t *testing.T) { expected := map[error]int{ discovery.ErrServerModeDisabled: http.StatusBadRequest, diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go index f3bd75a7ca..b490c6b5fc 100644 --- a/discovery/api/v1/generated.go +++ b/discovery/api/v1/generated.go @@ -130,6 +130,12 @@ type ClientInterface interface { // SearchPresentations request SearchPresentations(ctx context.Context, serviceID string, params *SearchPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeactivateServiceForDID request + DeactivateServiceForDID(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ActivateServiceForDID request + ActivateServiceForDID(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) GetPresentations(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -180,6 +186,30 @@ func (c *Client) SearchPresentations(ctx context.Context, serviceID string, para return c.Client.Do(req) } +func (c *Client) DeactivateServiceForDID(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeactivateServiceForDIDRequest(c.Server, serviceID, did) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ActivateServiceForDID(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewActivateServiceForDIDRequest(c.Server, serviceID, did) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewGetPresentationsRequest generates requests for GetPresentations func NewGetPresentationsRequest(server string, serviceID string, params *GetPresentationsParams) (*http.Request, error) { var err error @@ -335,6 +365,88 @@ func NewSearchPresentationsRequest(server string, serviceID string, params *Sear return req, nil } +// NewDeactivateServiceForDIDRequest generates requests for DeactivateServiceForDID +func NewDeactivateServiceForDIDRequest(server string, serviceID string, did string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, serviceID) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "did", runtime.ParamLocationPath, did) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/discovery/v1/%s/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewActivateServiceForDIDRequest generates requests for ActivateServiceForDID +func NewActivateServiceForDIDRequest(server string, serviceID string, did string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, serviceID) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "did", runtime.ParamLocationPath, did) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/discovery/v1/%s/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -388,6 +500,12 @@ type ClientWithResponsesInterface interface { // SearchPresentationsWithResponse request SearchPresentationsWithResponse(ctx context.Context, serviceID string, params *SearchPresentationsParams, reqEditors ...RequestEditorFn) (*SearchPresentationsResponse, error) + + // DeactivateServiceForDIDWithResponse request + DeactivateServiceForDIDWithResponse(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*DeactivateServiceForDIDResponse, error) + + // ActivateServiceForDIDWithResponse request + ActivateServiceForDIDWithResponse(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*ActivateServiceForDIDResponse, error) } type GetPresentationsResponse struct { @@ -495,6 +613,96 @@ func (r SearchPresentationsResponse) StatusCode() int { return 0 } +type DeactivateServiceForDIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *struct { + // Reason Description of why removal of the registration failed. + Reason string `json:"reason"` + } + ApplicationproblemJSON400 *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r DeactivateServiceForDIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeactivateServiceForDIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ActivateServiceForDIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *struct { + // Reason Description of why registration failed. + Reason string `json:"reason"` + } + ApplicationproblemJSON400 *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r ActivateServiceForDIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ActivateServiceForDIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + // GetPresentationsWithResponse request returning *GetPresentationsResponse func (c *ClientWithResponses) GetPresentationsWithResponse(ctx context.Context, serviceID string, params *GetPresentationsParams, reqEditors ...RequestEditorFn) (*GetPresentationsResponse, error) { rsp, err := c.GetPresentations(ctx, serviceID, params, reqEditors...) @@ -530,6 +738,24 @@ func (c *ClientWithResponses) SearchPresentationsWithResponse(ctx context.Contex return ParseSearchPresentationsResponse(rsp) } +// DeactivateServiceForDIDWithResponse request returning *DeactivateServiceForDIDResponse +func (c *ClientWithResponses) DeactivateServiceForDIDWithResponse(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*DeactivateServiceForDIDResponse, error) { + rsp, err := c.DeactivateServiceForDID(ctx, serviceID, did, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeactivateServiceForDIDResponse(rsp) +} + +// ActivateServiceForDIDWithResponse request returning *ActivateServiceForDIDResponse +func (c *ClientWithResponses) ActivateServiceForDIDWithResponse(ctx context.Context, serviceID string, did string, reqEditors ...RequestEditorFn) (*ActivateServiceForDIDResponse, error) { + rsp, err := c.ActivateServiceForDID(ctx, serviceID, did, reqEditors...) + if err != nil { + return nil, err + } + return ParseActivateServiceForDIDResponse(rsp) +} + // ParseGetPresentationsResponse parses an HTTP response from a GetPresentationsWithResponse call func ParseGetPresentationsResponse(rsp *http.Response) (*GetPresentationsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -665,17 +891,145 @@ func ParseSearchPresentationsResponse(rsp *http.Response) (*SearchPresentationsR return response, nil } +// ParseDeactivateServiceForDIDResponse parses an HTTP response from a DeactivateServiceForDIDWithResponse call +func ParseDeactivateServiceForDIDResponse(rsp *http.Response) (*DeactivateServiceForDIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeactivateServiceForDIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest struct { + // Reason Description of why removal of the registration failed. + Reason string `json:"reason"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseActivateServiceForDIDResponse parses an HTTP response from a ActivateServiceForDIDWithResponse call +func ParseActivateServiceForDIDResponse(rsp *http.Response) (*ActivateServiceForDIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ActivateServiceForDIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest struct { + // Reason Description of why registration failed. + Reason string `json:"reason"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + // ServerInterface represents all server handlers. type ServerInterface interface { - // Retrieves the presentations of a discovery service. + // Retrieves the presentations of a Discovery Service. // (GET /discovery/{serviceID}) GetPresentations(ctx echo.Context, serviceID string, params GetPresentationsParams) error - // Register a presentation on the discovery service. + // Register a presentation on the Discovery Service. // (POST /discovery/{serviceID}) RegisterPresentation(ctx echo.Context, serviceID string) error - // Searches for presentations registered on the discovery service. + // Searches for presentations registered on the Discovery Service. // (GET /discovery/{serviceID}/search) SearchPresentations(ctx echo.Context, serviceID string, params SearchPresentationsParams) error + // Client API to unregister the given DID from the Discovery Service. + // (DELETE /internal/discovery/v1/{serviceID}/{did}) + DeactivateServiceForDID(ctx echo.Context, serviceID string, did string) error + // Client API to activate a DID on the specified Discovery Service. + // (POST /internal/discovery/v1/{serviceID}/{did}) + ActivateServiceForDID(ctx echo.Context, serviceID string, did string) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -755,6 +1109,58 @@ func (w *ServerInterfaceWrapper) SearchPresentations(ctx echo.Context) error { return err } +// DeactivateServiceForDID converts echo context to params. +func (w *ServerInterfaceWrapper) DeactivateServiceForDID(ctx echo.Context) error { + var err error + // ------------- Path parameter "serviceID" ------------- + var serviceID string + + err = runtime.BindStyledParameterWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, ctx.Param("serviceID"), &serviceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter serviceID: %s", err)) + } + + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithLocation("simple", false, "did", runtime.ParamLocationPath, ctx.Param("did"), &did) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeactivateServiceForDID(ctx, serviceID, did) + return err +} + +// ActivateServiceForDID converts echo context to params. +func (w *ServerInterfaceWrapper) ActivateServiceForDID(ctx echo.Context) error { + var err error + // ------------- Path parameter "serviceID" ------------- + var serviceID string + + err = runtime.BindStyledParameterWithLocation("simple", false, "serviceID", runtime.ParamLocationPath, ctx.Param("serviceID"), &serviceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter serviceID: %s", err)) + } + + // ------------- Path parameter "did" ------------- + var did string + + err = runtime.BindStyledParameterWithLocation("simple", false, "did", runtime.ParamLocationPath, ctx.Param("did"), &did) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ActivateServiceForDID(ctx, serviceID, did) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -786,6 +1192,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/discovery/:serviceID", wrapper.GetPresentations) router.POST(baseURL+"/discovery/:serviceID", wrapper.RegisterPresentation) router.GET(baseURL+"/discovery/:serviceID/search", wrapper.SearchPresentations) + router.DELETE(baseURL+"/internal/discovery/v1/:serviceID/:did", wrapper.DeactivateServiceForDID) + router.POST(baseURL+"/internal/discovery/v1/:serviceID/:did", wrapper.ActivateServiceForDID) } @@ -923,17 +1331,159 @@ func (response SearchPresentationsdefaultApplicationProblemPlusJSONResponse) Vis return json.NewEncoder(w).Encode(response.Body) } +type DeactivateServiceForDIDRequestObject struct { + ServiceID string `json:"serviceID"` + Did string `json:"did"` +} + +type DeactivateServiceForDIDResponseObject interface { + VisitDeactivateServiceForDIDResponse(w http.ResponseWriter) error +} + +type DeactivateServiceForDID200Response struct { +} + +func (response DeactivateServiceForDID200Response) VisitDeactivateServiceForDIDResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeactivateServiceForDID202JSONResponse struct { + // Reason Description of why removal of the registration failed. + Reason string `json:"reason"` +} + +func (response DeactivateServiceForDID202JSONResponse) VisitDeactivateServiceForDIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(202) + + return json.NewEncoder(w).Encode(response) +} + +type DeactivateServiceForDID400ApplicationProblemPlusJSONResponse struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` +} + +func (response DeactivateServiceForDID400ApplicationProblemPlusJSONResponse) VisitDeactivateServiceForDIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DeactivateServiceForDIDdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response DeactivateServiceForDIDdefaultApplicationProblemPlusJSONResponse) VisitDeactivateServiceForDIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ActivateServiceForDIDRequestObject struct { + ServiceID string `json:"serviceID"` + Did string `json:"did"` +} + +type ActivateServiceForDIDResponseObject interface { + VisitActivateServiceForDIDResponse(w http.ResponseWriter) error +} + +type ActivateServiceForDID200Response struct { +} + +func (response ActivateServiceForDID200Response) VisitActivateServiceForDIDResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type ActivateServiceForDID202JSONResponse struct { + // Reason Description of why registration failed. + Reason string `json:"reason"` +} + +func (response ActivateServiceForDID202JSONResponse) VisitActivateServiceForDIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(202) + + return json.NewEncoder(w).Encode(response) +} + +type ActivateServiceForDID400ApplicationProblemPlusJSONResponse struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` +} + +func (response ActivateServiceForDID400ApplicationProblemPlusJSONResponse) VisitActivateServiceForDIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type ActivateServiceForDIDdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response ActivateServiceForDIDdefaultApplicationProblemPlusJSONResponse) VisitActivateServiceForDIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { - // Retrieves the presentations of a discovery service. + // Retrieves the presentations of a Discovery Service. // (GET /discovery/{serviceID}) GetPresentations(ctx context.Context, request GetPresentationsRequestObject) (GetPresentationsResponseObject, error) - // Register a presentation on the discovery service. + // Register a presentation on the Discovery Service. // (POST /discovery/{serviceID}) RegisterPresentation(ctx context.Context, request RegisterPresentationRequestObject) (RegisterPresentationResponseObject, error) - // Searches for presentations registered on the discovery service. + // Searches for presentations registered on the Discovery Service. // (GET /discovery/{serviceID}/search) SearchPresentations(ctx context.Context, request SearchPresentationsRequestObject) (SearchPresentationsResponseObject, error) + // Client API to unregister the given DID from the Discovery Service. + // (DELETE /internal/discovery/v1/{serviceID}/{did}) + DeactivateServiceForDID(ctx context.Context, request DeactivateServiceForDIDRequestObject) (DeactivateServiceForDIDResponseObject, error) + // Client API to activate a DID on the specified Discovery Service. + // (POST /internal/discovery/v1/{serviceID}/{did}) + ActivateServiceForDID(ctx context.Context, request ActivateServiceForDIDRequestObject) (ActivateServiceForDIDResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -1030,3 +1580,55 @@ func (sh *strictHandler) SearchPresentations(ctx echo.Context, serviceID string, } return nil } + +// DeactivateServiceForDID operation middleware +func (sh *strictHandler) DeactivateServiceForDID(ctx echo.Context, serviceID string, did string) error { + var request DeactivateServiceForDIDRequestObject + + request.ServiceID = serviceID + request.Did = did + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeactivateServiceForDID(ctx.Request().Context(), request.(DeactivateServiceForDIDRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeactivateServiceForDID") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(DeactivateServiceForDIDResponseObject); ok { + return validResponse.VisitDeactivateServiceForDIDResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// ActivateServiceForDID operation middleware +func (sh *strictHandler) ActivateServiceForDID(ctx echo.Context, serviceID string, did string) error { + var request ActivateServiceForDIDRequestObject + + request.ServiceID = serviceID + request.Did = did + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ActivateServiceForDID(ctx.Request().Context(), request.(ActivateServiceForDIDRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ActivateServiceForDID") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ActivateServiceForDIDResponseObject); ok { + return validResponse.VisitActivateServiceForDIDResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/discovery/client.go b/discovery/client.go new file mode 100644 index 0000000000..bb6d0bb7e0 --- /dev/null +++ b/discovery/client.go @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2024 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 discovery + +import ( + "context" + "errors" + "fmt" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/audit" + nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/discovery/api/v1/client" + "github.com/nuts-foundation/nuts-node/discovery/log" + "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "time" +) + +// clientRegistrationManager is a client component, responsible for managing registrations on a Discovery Service. +// It automatically refreshes registered Verifiable Presentations when they are about to expire. +type clientRegistrationManager interface { + activate(ctx context.Context, serviceID string, subjectDID did.DID) error + deactivate(ctx context.Context, serviceID string, subjectDID did.DID) error + // refresh is a blocking call to periodically refresh registrations. + // It checks for Verifiable Presentations that are about to expire, and should be refreshed on the Discovery Service. + // It will exit when the given context is cancelled. + refresh(ctx context.Context, interval time.Duration) +} + +var _ clientRegistrationManager = &defaultClientRegistrationManager{} + +type defaultClientRegistrationManager struct { + services map[string]ServiceDefinition + store *sqlStore + client client.HTTPClient + vcr vcr.VCR +} + +func newRegistrationManager(services map[string]ServiceDefinition, store *sqlStore, client client.HTTPClient, vcr vcr.VCR) *defaultClientRegistrationManager { + instance := &defaultClientRegistrationManager{ + services: services, + store: store, + client: client, + vcr: vcr, + } + return instance +} + +func (r *defaultClientRegistrationManager) activate(ctx context.Context, serviceID string, subjectDID did.DID) error { + service, serviceExists := r.services[serviceID] + if !serviceExists { + return ErrServiceNotFound + } + var asSoonAsPossible time.Time + if err := r.store.updatePresentationRefreshTime(serviceID, subjectDID, &asSoonAsPossible); err != nil { + return err + } + log.Logger().Debugf("Registering Verifiable Presentation on Discovery Service (service=%s, did=%s)", service.ID, subjectDID) + err := r.registerPresentation(ctx, subjectDID, service) + if err != nil { + // failed, will be retried on next scheduled refresh + return errors.Join(ErrPresentationRegistrationFailed, err) + } + log.Logger().Debugf("Successfully registered Verifiable Presentation on Discovery Service (service=%s, did=%s)", serviceID, subjectDID) + + // Set presentation to be refreshed before it expires + // TODO: When to refresh? For now, we refresh when the registration is about to expire (75% of max age) + refreshVPAfter := time.Now().Add(time.Duration(float64(service.PresentationMaxValidity)*0.75) * time.Second) + if err := r.store.updatePresentationRefreshTime(serviceID, subjectDID, &refreshVPAfter); err != nil { + return fmt.Errorf("unable to update Verifiable Presentation refresh time: %w", err) + } + return nil +} + +func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, serviceID string, subjectDID did.DID) error { + // delete DID/service combination from DB, so it won't be registered again + err := r.store.updatePresentationRefreshTime(serviceID, subjectDID, nil) + if err != nil { + return err + } + + // if the DID has an active registration, retract it + presentations, err := r.store.search(serviceID, map[string]string{ + "credentialSubject.id": subjectDID.String(), + }) + if err != nil { + return errors.Join(ErrPresentationRegistrationFailed, err) + } + if len(presentations) == 0 { + // no registration, nothing to do + return nil + } + // found an active registration, try to delete it from the discovery server + service := r.services[serviceID] + presentation, err := r.buildPresentation(ctx, subjectDID, service, nil, map[string]interface{}{ + "retract_jti": presentations[0].ID.String(), + }) + if err != nil { + return errors.Join(ErrPresentationRegistrationFailed, err) + } + err = r.client.Register(ctx, service.Endpoint, *presentation) + if err != nil { + return errors.Join(ErrPresentationRegistrationFailed, err) + } + return nil +} + +func (r *defaultClientRegistrationManager) registerPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition) error { + presentation, err := r.findCredentialsAndBuildPresentation(ctx, subjectDID, service) + if err != nil { + return err + } + return r.client.Register(ctx, service.Endpoint, *presentation) +} + +func (r *defaultClientRegistrationManager) findCredentialsAndBuildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition) (*vc.VerifiablePresentation, error) { + credentials, err := r.vcr.Wallet().List(ctx, subjectDID) + if err != nil { + return nil, err + } + matchingCredentials, _, err := service.PresentationDefinition.Match(credentials) + if err != nil { + return nil, fmt.Errorf("failed to match Discovery Service's Presentation Definition (service=%s, did=%s): %w", service.ID, subjectDID, err) + } + if len(matchingCredentials) == 0 { + return nil, fmt.Errorf("DID wallet does not have credentials required for registration on Discovery Service (service=%s, did=%s)", service.ID, subjectDID) + } + return r.buildPresentation(ctx, subjectDID, service, matchingCredentials, nil) +} + +func (r *defaultClientRegistrationManager) buildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, + credentials []vc.VerifiableCredential, additionalProperties map[string]interface{}) (*vc.VerifiablePresentation, error) { + nonce := nutsCrypto.GenerateNonce() + // Make sure the presentation is not valid for longer than the max validity as defined by the Service Definitio. + expires := time.Now().Add(time.Duration(service.PresentationMaxValidity-1) * time.Second).Truncate(time.Second) + return r.vcr.Wallet().BuildPresentation(ctx, credentials, holder.PresentationOptions{ + ProofOptions: proof.ProofOptions{ + Created: time.Now(), + Domain: &service.ID, + Expires: &expires, + Nonce: &nonce, + AdditionalProperties: additionalProperties, + }, + Format: vc.JWTPresentationProofFormat, + }, &subjectDID, false) +} + +func (r *defaultClientRegistrationManager) doRefresh(ctx context.Context, now time.Time) error { + log.Logger().Debug("Refreshing Verifiable Presentations on Discovery Services") + serviceIDs, dids, err := r.store.getPresentationsToBeRefreshed(now) + if err != nil { + return err + } + for i, serviceID := range serviceIDs { + if err := r.activate(ctx, serviceID, dids[i]); err != nil { + log.Logger().WithError(err).Warnf("Failed to refresh Verifiable Presentation (service=%s, did=%s)", serviceID, dids[i]) + } + } + return nil +} + +func (r *defaultClientRegistrationManager) refresh(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + // do the first refresh immediately + do := func() { + if err := r.doRefresh(audit.Context(ctx, "app", ModuleName, "RefreshVerifiablePresentations"), time.Now()); err != nil { + log.Logger().WithError(err).Errorf("Failed to refresh Verifiable Presentations") + } + } + do() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + do() + } + } +} diff --git a/discovery/client_test.go b/discovery/client_test.go new file mode 100644 index 0000000000..fd232d17f5 --- /dev/null +++ b/discovery/client_test.go @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2024 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 discovery + +import ( + "context" + "errors" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/discovery/api/v1/client" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "sync" + "testing" + "time" +) + +func Test_scheduledRegistrationManager_register(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("immediate registration", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + invoker.EXPECT().Register(gomock.Any(), "http://example.com/usecase", vpAlice) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return([]vc.VerifiableCredential{vcAlice}, nil) + wallet.EXPECT().BuildPresentation(gomock.Any(), []vc.VerifiableCredential{vcAlice}, gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + err := manager.activate(audit.TestContext(), testServiceID, aliceDID) + + require.NoError(t, err) + }) + t.Run("registration fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("invoker error")) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return([]vc.VerifiableCredential{vcAlice}, nil) + wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + err := manager.activate(audit.TestContext(), testServiceID, aliceDID) + + require.ErrorIs(t, err, ErrPresentationRegistrationFailed) + require.ErrorContains(t, err, "invoker error") + }) + t.Run("no matching credentials", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, nil) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + err := manager.activate(audit.TestContext(), testServiceID, aliceDID) + + require.ErrorIs(t, err, ErrPresentationRegistrationFailed) + require.ErrorContains(t, err, "DID wallet does not have credentials required for registration on Discovery Service (service=usecase_v1, did=did:example:alice)") + }) + t.Run("unknown service", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + err := manager.activate(audit.TestContext(), "unknown", aliceDID) + + require.EqualError(t, err, "discovery service not found") + }) +} + +func Test_scheduledRegistrationManager_deregister(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("not registered", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + err := manager.deactivate(audit.TestContext(), testServiceID, aliceDID) + + assert.NoError(t, err) + }) + t.Run("registered", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + tag := Tag("taggy") + require.NoError(t, store.add(testServiceID, vpAlice, &tag)) + + err := manager.deactivate(audit.TestContext(), testServiceID, aliceDID) + + assert.NoError(t, err) + }) + t.Run("deregistering from Discovery Service fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("remote error")) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + tag := Tag("taggy") + require.NoError(t, store.add(testServiceID, vpAlice, &tag)) + + err := manager.deactivate(audit.TestContext(), testServiceID, aliceDID) + + require.ErrorIs(t, err, ErrPresentationRegistrationFailed) + require.ErrorContains(t, err, "remote error") + }) +} + +func Test_scheduledRegistrationManager_doRefreshRegistrations(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("no registrations", func(t *testing.T) { + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + err := manager.doRefresh(audit.TestContext(), time.Now()) + + require.NoError(t, err) + }) + t.Run("2 VPs to refresh, first one fails, second one succeeds", func(t *testing.T) { + store := setupStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + gomock.InOrder( + invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("remote error")), + invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()), + ) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + // Alice + _ = store.updatePresentationRefreshTime(testServiceID, aliceDID, &time.Time{}) + wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &aliceDID, false).Return(&vpAlice, nil) + wallet.EXPECT().List(gomock.Any(), aliceDID).Return([]vc.VerifiableCredential{vcAlice}, nil) + // Bob + _ = store.updatePresentationRefreshTime(testServiceID, bobDID, &time.Time{}) + wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &bobDID, false).Return(&vpBob, nil) + wallet.EXPECT().List(gomock.Any(), bobDID).Return([]vc.VerifiableCredential{vcBob}, nil) + + err := manager.doRefresh(audit.TestContext(), time.Now()) + + require.NoError(t, err) + }) +} + +func Test_scheduledRegistrationManager_refreshRegistrations(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + t.Run("context cancel stops the loop", func(t *testing.T) { + store := setupStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + invoker := client.NewMockHTTPClient(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() + manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR) + + ctx, cancel := context.WithCancel(context.Background()) + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + manager.refresh(ctx, time.Millisecond) + }() + // make sure the loop has at least once + time.Sleep(5 * time.Millisecond) + // Make sure the function exits when the context is cancelled + cancel() + wg.Wait() + }) +} diff --git a/discovery/cmd/cmd.go b/discovery/cmd/cmd.go index 302f812471..bbe1d90f80 100644 --- a/discovery/cmd/cmd.go +++ b/discovery/cmd/cmd.go @@ -33,5 +33,9 @@ func FlagSet() *pflag.FlagSet { flagSet.StringSlice("discovery.server.definition_ids", defs.Server.DefinitionIDs, "IDs of the Discovery Service Definitions for which to act as server. "+ "If an ID does not map to a loaded service definition, the node will fail to start.") + flagSet.Duration("discovery.client.registration_refresh_interval", defs.Client.RegistrationRefreshInterval, + "Interval at which the client should refresh checks for registrations to refresh on the configured Discovery Services,"+ + "in Golang time.Duration string format (e.g. 1s). "+ + "Note that it only will actually refresh registrations that about to expire (less than 1/4th of their lifetime left).") return flagSet } diff --git a/discovery/config.go b/discovery/config.go index 1a432cbbf6..5f18d02556 100644 --- a/discovery/config.go +++ b/discovery/config.go @@ -18,9 +18,12 @@ package discovery +import "time" + // Config holds the config of the module type Config struct { Server ServerConfig `koanf:"server"` + Client ClientConfig `koanf:"client"` Definitions ServiceDefinitionsConfig `koanf:"definitions"` } @@ -35,14 +38,19 @@ type ServerConfig struct { DefinitionIDs []string `koanf:"definition_ids"` } +// ClientConfig holds the config for the client +type ClientConfig struct { + // RegistrationRefreshInterval specifies how often the client should refresh its registrations on Discovery Services. + // At the same interval, failed registrations are refreshed. + RegistrationRefreshInterval time.Duration `koanf:"registration_refresh_interval"` +} + // DefaultConfig returns the default configuration. func DefaultConfig() Config { return Config{ Server: ServerConfig{}, + Client: ClientConfig{ + RegistrationRefreshInterval: 10 * time.Minute, + }, } } - -// IsServer returns true if the node act as Discovery Server. -func (c Config) IsServer() bool { - return len(c.Server.DefinitionIDs) > 0 -} diff --git a/discovery/config_test.go b/discovery/config_test.go new file mode 100644 index 0000000000..643f485c4a --- /dev/null +++ b/discovery/config_test.go @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 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 discovery + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + assert.NotEmpty(t, DefaultConfig().Client.RegistrationRefreshInterval) +} diff --git a/discovery/interface.go b/discovery/interface.go index 218810ed2b..bb81b3447e 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -19,7 +19,9 @@ package discovery import ( + "context" "errors" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "math" "strconv" @@ -83,11 +85,14 @@ var ErrServiceNotFound = errors.New("discovery service not found") // but a presentation with this ID already exists. 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") + // Server defines the API for Discovery Servers. type Server interface { - // Add registers a presentation on the given Discovery Service. + // Register registers a presentation on the given Discovery Service. // If the presentation is not valid, or it does not conform to the Service ServiceDefinition, it returns an error. - Add(serviceID string, presentation vc.VerifiablePresentation) error + Register(serviceID string, presentation vc.VerifiablePresentation) error // Get retrieves the presentations for the given service, starting at the given timestamp. Get(serviceID string, startAt *Tag) ([]vc.VerifiablePresentation, *Tag, error) } @@ -97,6 +102,17 @@ type Client interface { // Search searches for presentations which credential(s) match the given query. // Query parameters are formatted as simple JSON paths, e.g. "issuer" or "credentialSubject.name". Search(serviceID string, query map[string]string) ([]SearchResult, error) + + // ActivateServiceForDID causes a DID, in the form of a Verifiable Presentation, to be registered on a Discovery Service. + // Registration will be attempted immediately, and automatically refreshed. + // If the initial registration fails with ErrPresentationRegistrationFailed, registration will be retried. + // If the function is called again for the same service/DID combination, it will try to refresh the registration. + // It returns an error if the service or DID is invalid/unknown. + ActivateServiceForDID(ctx context.Context, serviceID string, subjectDID did.DID) error + + // DeactivateServiceForDID removes the registration of a DID on a Discovery Service. + // It returns an error if the service or DID is invalid/unknown. + DeactivateServiceForDID(ctx context.Context, serviceID string, subjectDID did.DID) error } // SearchResult is a single result of a search operation. diff --git a/discovery/mock.go b/discovery/mock.go index 4e3c21c67c..93b481fea8 100644 --- a/discovery/mock.go +++ b/discovery/mock.go @@ -10,8 +10,10 @@ package discovery import ( + context "context" reflect "reflect" + did "github.com/nuts-foundation/go-did/did" vc "github.com/nuts-foundation/go-did/vc" gomock "go.uber.org/mock/gomock" ) @@ -39,20 +41,6 @@ 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 *Tag) ([]vc.VerifiablePresentation, *Tag, error) { m.ctrl.T.Helper() @@ -69,6 +57,20 @@ func (mr *MockServerMockRecorder) Get(serviceID, startAt any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockServer)(nil).Get), serviceID, startAt) } +// Register mocks base method. +func (m *MockServer) Register(serviceID string, presentation vc.VerifiablePresentation) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Register", serviceID, presentation) + ret0, _ := ret[0].(error) + return ret0 +} + +// Register indicates an expected call of Register. +func (mr *MockServerMockRecorder) Register(serviceID, presentation any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Register", reflect.TypeOf((*MockServer)(nil).Register), serviceID, presentation) +} + // MockClient is a mock of Client interface. type MockClient struct { ctrl *gomock.Controller @@ -92,6 +94,34 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { return m.recorder } +// ActivateServiceForDID mocks base method. +func (m *MockClient) ActivateServiceForDID(ctx context.Context, serviceID string, subjectDID did.DID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ActivateServiceForDID", ctx, serviceID, subjectDID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ActivateServiceForDID indicates an expected call of ActivateServiceForDID. +func (mr *MockClientMockRecorder) ActivateServiceForDID(ctx, serviceID, subjectDID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivateServiceForDID", reflect.TypeOf((*MockClient)(nil).ActivateServiceForDID), ctx, serviceID, subjectDID) +} + +// DeactivateServiceForDID mocks base method. +func (m *MockClient) DeactivateServiceForDID(ctx context.Context, serviceID string, subjectDID did.DID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeactivateServiceForDID", ctx, serviceID, subjectDID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeactivateServiceForDID indicates an expected call of DeactivateServiceForDID. +func (mr *MockClientMockRecorder) DeactivateServiceForDID(ctx, serviceID, subjectDID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeactivateServiceForDID", reflect.TypeOf((*MockClient)(nil).DeactivateServiceForDID), ctx, serviceID, subjectDID) +} + // Search mocks base method. func (m *MockClient) Search(serviceID string, query map[string]string) ([]SearchResult, error) { m.ctrl.T.Helper() diff --git a/discovery/module.go b/discovery/module.go index a30b776b73..522c91246c 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -19,24 +19,29 @@ package discovery import ( + "context" "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/discovery/api/v1/client" "github.com/nuts-foundation/nuts-node/discovery/log" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vdr/management" "os" "path" "strings" + "sync" "time" ) const ModuleName = "Discovery" -// ErrServerModeDisabled is returned when a client invokes a Discovery Server (Add or Get) operation on the node, +// ErrServerModeDisabled is returned when a client invokes a Discovery Server (Register or Get) operation on the node, // for a Discovery Service which it doesn't serve. var ErrServerModeDisabled = errors.New("node is not a discovery server for this service") @@ -63,24 +68,34 @@ var _ Client = &Module{} var retractionPresentationType = ssi.MustParseURI("RetractedVerifiablePresentation") // New creates a new Module. -func New(storageInstance storage.Engine, vcrInstance vcr.VCR) *Module { - return &Module{ +func New(storageInstance storage.Engine, vcrInstance vcr.VCR, documentOwner management.DocumentOwner) *Module { + m := &Module{ storageInstance: storageInstance, vcrInstance: vcrInstance, + documentOwner: documentOwner, } + m.ctx, m.cancel = context.WithCancel(context.Background()) + m.routines = new(sync.WaitGroup) + return m } // Module is the main entry point for discovery services. type Module struct { - config Config - storageInstance storage.Engine - store *sqlStore - serverDefinitions map[string]ServiceDefinition - allDefinitions map[string]ServiceDefinition - vcrInstance vcr.VCR + config Config + httpClient client.HTTPClient + storageInstance storage.Engine + store *sqlStore + registrationManager clientRegistrationManager + serverDefinitions map[string]ServiceDefinition + allDefinitions map[string]ServiceDefinition + vcrInstance vcr.VCR + documentOwner management.DocumentOwner + ctx context.Context + cancel context.CancelFunc + routines *sync.WaitGroup } -func (m *Module) Configure(_ core.ServerConfig) error { +func (m *Module) Configure(serverConfig core.ServerConfig) error { if m.config.Definitions.Directory == "" { return nil } @@ -101,6 +116,7 @@ func (m *Module) Configure(_ core.ServerConfig) error { } m.serverDefinitions = serverDefinitions } + m.httpClient = client.New(serverConfig.Strictmode, serverConfig.HTTPClient.Timeout, nil) return nil } @@ -110,10 +126,18 @@ func (m *Module) Start() error { if err != nil { return err } + m.registrationManager = newRegistrationManager(m.allDefinitions, m.store, m.httpClient, m.vcrInstance) + m.routines.Add(1) + go func() { + defer m.routines.Done() + m.registrationManager.refresh(m.ctx, m.config.Client.RegistrationRefreshInterval) + }() return nil } func (m *Module) Shutdown() error { + m.cancel() + m.routines.Wait() return nil } @@ -125,9 +149,9 @@ func (m *Module) Config() interface{} { return &m.config } -// Add registers a presentation on the given Discovery Service. +// Register is a Discovery Server function that registers a presentation on the given Discovery Service. // See interface.go for more information. -func (m *Module) Add(serviceID string, presentation vc.VerifiablePresentation) error { +func (m *Module) Register(serviceID string, presentation vc.VerifiablePresentation) error { // First, simple sanity checks definition, isServer := m.serverDefinitions[serviceID] if !isServer { @@ -224,7 +248,7 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable return nil } -// Get retrieves the presentations for the given service, starting at the given tag. +// Get is a Discovery Server function that retrieves the presentations for the given service, starting at the given tag. // See interface.go for more information. func (m *Module) Get(serviceID string, tag *Tag) ([]vc.VerifiablePresentation, *Tag, error) { if _, exists := m.serverDefinitions[serviceID]; !exists { @@ -233,6 +257,33 @@ func (m *Module) Get(serviceID string, tag *Tag) ([]vc.VerifiablePresentation, * return m.store.get(serviceID, tag) } +// ActivateServiceForDID is a Discovery Client function that activates a service for a DID. +// See interface.go for more information. +func (m *Module) ActivateServiceForDID(ctx context.Context, serviceID string, subjectDID did.DID) error { + log.Logger().Debugf("Activating service for DID (did=%s, service=%s)", subjectDID, serviceID) + isOwner, err := m.documentOwner.IsOwner(ctx, subjectDID) + if err != nil { + return err + } + if !isOwner { + return errors.New("not owner of DID") + } + err = m.registrationManager.activate(ctx, serviceID, subjectDID) + if errors.Is(err, ErrPresentationRegistrationFailed) { + log.Logger().WithError(err).Warnf("Presentation registration failed, will be retried later (did=%s,service=%s)", subjectDID, serviceID) + } else if err == nil { + log.Logger().Infof("Successfully activated service for DID (did=%s,service=%s)", subjectDID, serviceID) + } + return err +} + +// DeactivateServiceForDID is a Discovery Client function that deactivates a service for a DID. +// See interface.go for more information. +func (m *Module) DeactivateServiceForDID(ctx context.Context, serviceID string, subjectDID did.DID) error { + log.Logger().Infof("Deactivating service for DID (did=%s, service=%s)", subjectDID, serviceID) + return m.registrationManager.deactivate(ctx, serviceID, subjectDID) +} + func loadDefinitions(directory string) (map[string]ServiceDefinition, error) { entries, err := os.ReadDir(directory) if err != nil { @@ -260,6 +311,8 @@ func loadDefinitions(directory string) (map[string]ServiceDefinition, error) { return result, nil } +// Search is a Discovery Client function that searches for presentations which credential(s) match the given query. +// See interface.go for more information. func (m *Module) Search(serviceID string, query map[string]string) ([]SearchResult, error) { service, exists := m.allDefinitions[serviceID] if !exists { diff --git a/discovery/module_test.go b/discovery/module_test.go index 8367c9574c..73f1133d94 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -19,6 +19,7 @@ package discovery import ( + "context" "encoding/json" "errors" "github.com/lestrrat-go/jwx/v2/jwt" @@ -26,7 +27,9 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/verifier" + "github.com/nuts-foundation/nuts-node/vdr/management" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -39,24 +42,25 @@ func TestModule_Name(t *testing.T) { } func TestModule_Shutdown(t *testing.T) { - assert.NoError(t, (&Module{}).Shutdown()) + module, _, _ := setupModule(t, storage.NewTestStorageEngine(t)) + require.NoError(t, module.Shutdown()) } -func Test_Module_Add(t *testing.T) { +func Test_Module_Register(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) t.Run("not a server", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) - err := m.Add("other", vpAlice) + err := m.Register("other", vpAlice) require.EqualError(t, err, "node is not a discovery server for this service") }) t.Run("VP verification fails (e.g. invalid signature)", func(t *testing.T) { - m, presentationVerifier := setupModule(t, storageEngine) + m, presentationVerifier, _ := setupModule(t, storageEngine) presentationVerifier.EXPECT().VerifyVP(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("failed")) - err := m.Add(testServiceID, vpAlice) + err := m.Register(testServiceID, vpAlice) require.EqualError(t, err, "presentation is invalid for registration\npresentation verification failed: failed") _, tag, err := m.Get(testServiceID, nil) @@ -65,54 +69,54 @@ func Test_Module_Add(t *testing.T) { assert.Equal(t, expectedTag, *tag) }) t.Run("already exists", func(t *testing.T) { - m, presentationVerifier := setupModule(t, storageEngine) + m, presentationVerifier, _ := setupModule(t, storageEngine) presentationVerifier.EXPECT().VerifyVP(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) - err := m.Add(testServiceID, vpAlice) + err := m.Register(testServiceID, vpAlice) assert.NoError(t, err) - err = m.Add(testServiceID, vpAlice) + err = m.Register(testServiceID, vpAlice) assert.ErrorIs(t, err, ErrPresentationAlreadyExists) }) t.Run("valid for too long", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) def := m.allDefinitions[testServiceID] def.PresentationMaxValidity = 1 m.allDefinitions[testServiceID] = def m.serverDefinitions[testServiceID] = def - err := m.Add(testServiceID, vpAlice) + err := m.Register(testServiceID, vpAlice) assert.EqualError(t, err, "presentation is invalid for registration\npresentation is valid for too long (max 1s)") }) t.Run("no expiration", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) - err := m.Add(testServiceID, createPresentationCustom(aliceDID, func(claims map[string]interface{}, _ *vc.VerifiablePresentation) { + m, _, _ := setupModule(t, storageEngine) + err := m.Register(testServiceID, createPresentationCustom(aliceDID, func(claims map[string]interface{}, _ *vc.VerifiablePresentation) { claims[jwt.AudienceKey] = []string{testServiceID} delete(claims, "exp") })) assert.ErrorIs(t, err, errPresentationWithoutExpiration) }) t.Run("presentation does not contain an ID", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) vpWithoutID := createPresentationCustom(aliceDID, func(claims map[string]interface{}, _ *vc.VerifiablePresentation) { claims[jwt.AudienceKey] = []string{testServiceID} delete(claims, "jti") }, vcAlice) - err := m.Add(testServiceID, vpWithoutID) + err := m.Register(testServiceID, vpWithoutID) assert.ErrorIs(t, err, errPresentationWithoutID) }) t.Run("not a JWT", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) - err := m.Add(testServiceID, vc.VerifiablePresentation{}) + m, _, _ := setupModule(t, storageEngine) + err := m.Register(testServiceID, vc.VerifiablePresentation{}) assert.ErrorIs(t, err, errUnsupportedPresentationFormat) }) t.Run("registration", func(t *testing.T) { t.Run("ok", func(t *testing.T) { - m, presentationVerifier := setupModule(t, storageEngine) + m, presentationVerifier, _ := setupModule(t, storageEngine) presentationVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil) - err := m.Add(testServiceID, vpAlice) + err := m.Register(testServiceID, vpAlice) require.NoError(t, err) _, tag, err := m.Get(testServiceID, nil) @@ -120,7 +124,7 @@ func Test_Module_Add(t *testing.T) { assert.Equal(t, "1", string(*tag)[tagPrefixLength:]) }) t.Run("valid longer than its credentials", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) vcAlice := createCredential(authorityDID, aliceDID, nil, func(claims map[string]interface{}) { claims[jwt.AudienceKey] = []string{testServiceID} @@ -129,17 +133,17 @@ func Test_Module_Add(t *testing.T) { vpAlice := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { claims[jwt.AudienceKey] = []string{testServiceID} }, vcAlice) - err := m.Add(testServiceID, vpAlice) + err := m.Register(testServiceID, vpAlice) assert.ErrorIs(t, err, errPresentationValidityExceedsCredentials) }) t.Run("not conform to Presentation Definition", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) // Presentation Definition only allows did:example DIDs otherVP := createPresentationCustom(unsupportedDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { claims[jwt.AudienceKey] = []string{testServiceID} }, createCredential(unsupportedDID, unsupportedDID, nil, nil)) - err := m.Add(testServiceID, otherVP) + err := m.Register(testServiceID, otherVP) require.ErrorContains(t, err, "presentation does not fulfill Presentation ServiceDefinition") _, tag, _ := m.Get(testServiceID, nil) @@ -153,55 +157,55 @@ func Test_Module_Add(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }) t.Run("ok", func(t *testing.T) { - m, presentationVerifier := setupModule(t, storageEngine) + m, presentationVerifier, _ := setupModule(t, storageEngine) presentationVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Times(2) - err := m.Add(testServiceID, vpAlice) + err := m.Register(testServiceID, vpAlice) require.NoError(t, err) - err = m.Add(testServiceID, vpAliceRetract) + err = m.Register(testServiceID, vpAliceRetract) assert.NoError(t, err) }) t.Run("non-existent presentation", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) - err := m.Add(testServiceID, vpAliceRetract) + m, _, _ := setupModule(t, storageEngine) + err := m.Register(testServiceID, vpAliceRetract) assert.ErrorIs(t, err, errRetractionReferencesUnknownPresentation) }) t.Run("must not contain credentials", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { vp.Type = append(vp.Type, retractionPresentationType) claims[jwt.AudienceKey] = []string{testServiceID} }, vcAlice) - err := m.Add(testServiceID, vp) + err := m.Register(testServiceID, vp) assert.ErrorIs(t, err, errRetractionContainsCredentials) }) t.Run("missing 'retract_jti' claim", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { vp.Type = append(vp.Type, retractionPresentationType) claims[jwt.AudienceKey] = []string{testServiceID} }) - err := m.Add(testServiceID, vp) + err := m.Register(testServiceID, vp) assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) t.Run("'retract_jti' claim is not a string", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + 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 claims[jwt.AudienceKey] = []string{testServiceID} }) - err := m.Add(testServiceID, vp) + err := m.Register(testServiceID, vp) assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) t.Run("'retract_jti' claim is an empty string", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) vp := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { vp.Type = append(vp.Type, retractionPresentationType) claims["retract_jti"] = "" claims[jwt.AudienceKey] = []string{testServiceID} }) - err := m.Add(testServiceID, vp) + err := m.Register(testServiceID, vp) assert.ErrorIs(t, err, errInvalidRetractionJTIClaim) }) }) @@ -211,7 +215,7 @@ func Test_Module_Get(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) t.Run("ok", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) require.NoError(t, m.store.add(testServiceID, vpAlice, nil)) presentations, tag, err := m.Get(testServiceID, nil) assert.NoError(t, err) @@ -219,33 +223,35 @@ func Test_Module_Get(t *testing.T) { assert.Equal(t, "1", string(*tag)[tagPrefixLength:]) }) t.Run("ok - retrieve delta", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) require.NoError(t, m.store.add(testServiceID, vpAlice, nil)) presentations, _, err := m.Get(testServiceID, nil) require.NoError(t, err) require.Len(t, presentations, 1) }) t.Run("not a server for this service ID", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) _, _, err := m.Get("other", nil) assert.ErrorIs(t, err, ErrServerModeDisabled) }) } -func setupModule(t *testing.T, storageInstance storage.Engine) (*Module, *verifier.MockVerifier) { +func setupModule(t *testing.T, storageInstance storage.Engine) (*Module, *verifier.MockVerifier, *management.MockDocumentOwner) { resetStore(t, storageInstance.GetSQLDatabase()) ctrl := gomock.NewController(t) mockVerifier := verifier.NewMockVerifier(ctrl) mockVCR := vcr.NewMockVCR(ctrl) mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() - m := New(storageInstance, mockVCR) + documentOwner := management.NewMockDocumentOwner(ctrl) + m := New(storageInstance, mockVCR, documentOwner) + m.config = DefaultConfig() require.NoError(t, m.Configure(core.ServerConfig{})) m.allDefinitions = testDefinitions() m.serverDefinitions = map[string]ServiceDefinition{ testServiceID: m.allDefinitions[testServiceID], } require.NoError(t, m.Start()) - return m, mockVerifier + return m, mockVerifier, documentOwner } func TestModule_Configure(t *testing.T) { @@ -292,7 +298,7 @@ func TestModule_Search(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) t.Run("ok", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) require.NoError(t, m.store.add(testServiceID, vpAlice, nil)) results, err := m.Search(testServiceID, map[string]string{ "credentialSubject.id": aliceDID.String(), @@ -308,8 +314,34 @@ func TestModule_Search(t *testing.T) { assert.JSONEq(t, string(expectedJSON), string(actualJSON)) }) t.Run("unknown service ID", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, _, _ := setupModule(t, storageEngine) _, err := m.Search("unknown", nil) assert.ErrorIs(t, err, ErrServiceNotFound) }) } + +func TestModule_ActivateServiceForDID(t *testing.T) { + t.Run("not owned", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + m, _, documentOwner := setupModule(t, storageEngine) + documentOwner.EXPECT().IsOwner(gomock.Any(), aliceDID).Return(false, nil) + + err := m.ActivateServiceForDID(context.Background(), testServiceID, aliceDID) + + require.EqualError(t, err, "not owner of DID") + }) + t.Run("ok, but couldn't register presentation -> maps to ErrRegistrationFailed", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + m, _, documentOwner := setupModule(t, storageEngine) + wallet := holder.NewMockWallet(gomock.NewController(t)) + m.vcrInstance.(*vcr.MockVCR).EXPECT().Wallet().Return(wallet).MinTimes(1) + wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("failed")).MinTimes(1) + documentOwner.EXPECT().IsOwner(gomock.Any(), aliceDID).Return(true, nil) + + err := m.ActivateServiceForDID(context.Background(), testServiceID, aliceDID) + + require.ErrorIs(t, err, ErrPresentationRegistrationFailed) + }) +} diff --git a/discovery/store.go b/discovery/store.go index b9ffb1e081..34494d9b10 100644 --- a/discovery/store.go +++ b/discovery/store.go @@ -21,6 +21,7 @@ package discovery import ( "errors" "fmt" + "github.com/nuts-foundation/go-did/did" "math/rand" "strconv" "strings" @@ -103,6 +104,21 @@ func (l credentialPropertyRecord) TableName() string { return "discovery_credential_prop" } +// presentationRefreshRecord is a tab-keeping record for clients to keep track of which DIDs should be registered on which Discovery Services. +type presentationRefreshRecord struct { + // ServiceID refers to the entry record in discovery_service + ServiceID string `gorm:"primaryKey"` + // Did is Did that should be registered on the service. + Did string `gorm:"primaryKey"` + // NextRefresh is the timestamp (seconds since Unix epoch) when the registration on the Discovery Service should be refreshed. + NextRefresh int64 +} + +// TableName returns the table name for this DTO. +func (l presentationRefreshRecord) TableName() string { + return "discovery_presentation_refresh" +} + type sqlStore struct { db *gorm.DB writeLock sync.Mutex @@ -402,6 +418,40 @@ func (s *sqlStore) removeExpired() (int, error) { return int(result.RowsAffected), nil } +// updatePresentationRefreshTime creates/updates the next refresh time for a Verifiable Presentation on a Discovery Service. +// If nextRegistration is nil, the entry will be removed from the database. +func (s *sqlStore) updatePresentationRefreshTime(serviceID string, subjectDID did.DID, nextRefresh *time.Time) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if nextRefresh == nil { + // Delete registration + return tx.Delete(&presentationRefreshRecord{}, "service_id = ? AND did = ?", serviceID, subjectDID.String()).Error + } + // Create or update it + return tx.Save(presentationRefreshRecord{Did: subjectDID.String(), ServiceID: serviceID, NextRefresh: nextRefresh.Unix()}).Error + }) +} + +// getPresentationsToBeRefreshed returns all DID discovery service registrations that are due for refreshing. +// It returns a slice of service IDs and associated DIDs. +func (s *sqlStore) getPresentationsToBeRefreshed(now time.Time) ([]string, []did.DID, error) { + var rows []presentationRefreshRecord + if err := s.db.Find(&rows, "next_refresh < ?", now.Unix()).Error; err != nil { + return nil, nil, err + } + var dids []did.DID + var serviceIDs []string + for _, row := range rows { + parsedDID, err := did.ParseDID(row.Did) + if err != nil { + log.Logger().WithError(err).Errorf("Invalid DID in discovery presentation refresh table: %s", row.Did) + continue + } + dids = append(dids, *parsedDID) + serviceIDs = append(serviceIDs, row.ServiceID) + } + return serviceIDs, dids, 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) { diff --git a/discovery/store_test.go b/discovery/store_test.go index 938c9eade5..6f24ecbc9a 100644 --- a/discovery/store_test.go +++ b/discovery/store_test.go @@ -19,6 +19,7 @@ package discovery import ( + "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" @@ -26,6 +27,7 @@ import ( "gorm.io/gorm" "sync" "testing" + "time" ) func Test_sqlStore_exists(t *testing.T) { @@ -355,6 +357,56 @@ func Test_sqlStore_search(t *testing.T) { }) } +func Test_sqlStore_getStaleDIDRegistrations(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { + _ = storageEngine.Shutdown() + }) + + now := time.Now() + t.Run("empty list", func(t *testing.T) { + c := setupStore(t, storageEngine.GetSQLDatabase()) + serviceIDs, dids, err := c.getPresentationsToBeRefreshed(now) + require.NoError(t, err) + assert.Empty(t, serviceIDs) + assert.Empty(t, dids) + }) + t.Run("1 entry, not stale", func(t *testing.T) { + c := setupStore(t, storageEngine.GetSQLDatabase()) + require.NoError(t, c.updatePresentationRefreshTime(testServiceID, aliceDID, &now)) + serviceIDs, dids, err := c.getPresentationsToBeRefreshed(time.Now().Add(-1 * time.Hour)) + require.NoError(t, err) + assert.Empty(t, serviceIDs) + assert.Empty(t, dids) + }) + t.Run("1 entry, stale", func(t *testing.T) { + c := setupStore(t, storageEngine.GetSQLDatabase()) + require.NoError(t, c.updatePresentationRefreshTime(testServiceID, aliceDID, &now)) + serviceIDs, dids, err := c.getPresentationsToBeRefreshed(time.Now().Add(time.Hour)) + require.NoError(t, err) + assert.Equal(t, []string{testServiceID}, serviceIDs) + assert.Equal(t, []did.DID{aliceDID}, dids) + }) + t.Run("does not return removed entry", func(t *testing.T) { + c := setupStore(t, storageEngine.GetSQLDatabase()) + require.NoError(t, c.updatePresentationRefreshTime(testServiceID, aliceDID, &now)) + + // Assert it's there + serviceIDs, dids, err := c.getPresentationsToBeRefreshed(time.Now().Add(time.Hour)) + require.NoError(t, err) + assert.Equal(t, []string{testServiceID}, serviceIDs) + assert.Equal(t, []did.DID{aliceDID}, dids) + + // Remove it + require.NoError(t, c.updatePresentationRefreshTime(testServiceID, aliceDID, nil)) + serviceIDs, dids, err = c.getPresentationsToBeRefreshed(time.Now().Add(time.Hour)) + require.NoError(t, err) + assert.Empty(t, serviceIDs) + assert.Empty(t, dids) + }) +} + func setupStore(t *testing.T, db *gorm.DB) *sqlStore { resetStore(t, db) defs := testDefinitions() diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index a3a913edbb..b1593be79e 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -1,7 +1,7 @@ openapi: "3.0.0" info: title: Nuts Discovery Service API spec - description: API specification for discovery services available within Nuts node + description: API specification for Discovery Services available within Nuts node version: 1.0.0 license: name: GPLv3 @@ -16,9 +16,9 @@ paths: schema: type: string get: - summary: Retrieves the presentations of a discovery service. + summary: Retrieves the presentations of a Discovery Service. description: | - An API provided by the discovery server to retrieve the presentations of a discovery service, starting at the given tag. + An API provided by the discovery server to retrieve the presentations of a Discovery Service, starting at the given tag. The client should provide the tag it was returned in the last response. If no tag is given, it will return all presentations. @@ -42,7 +42,7 @@ paths: default: $ref: "../common/error_response.yaml" post: - summary: Register a presentation on the discovery service. + summary: Register a presentation on the Discovery Service. description: | An API provided by the discovery server that adds a presentation to the service. The presentation must be signed by subject of the credentials it contains. @@ -58,7 +58,7 @@ paths: tags: - discovery requestBody: - description: The presentation to register to the discovery service. + description: The presentation to register to the Discovery Service. required: true content: application/json: @@ -66,7 +66,7 @@ paths: $ref: "#/components/schemas/VerifiablePresentation" responses: "201": - description: Presentation was registered on the discovery service. + description: Presentation was registered on the Discovery Service. "400": $ref: "../common/error_response.yaml" default: @@ -90,9 +90,9 @@ paths: style: form explode: true get: - summary: Searches for presentations registered on the discovery service. + summary: Searches for presentations registered on the Discovery Service. description: | - An API of the discovery client that searches for presentations on the discovery service, + An API of the discovery client that searches for presentations on the Discovery Service, whose credentials match the given query parameter. It queries the client's local copy of the Discovery Service which is periodically synchronized with the Discovery Server. This means new registrations might not immediately show up, depending on the client refresh interval. @@ -127,6 +127,87 @@ paths: $ref: "#/components/schemas/SearchResult" default: $ref: "../common/error_response.yaml" + /internal/discovery/v1/{serviceID}/{did}: + parameters: + - name: serviceID + in: path + required: true + schema: + type: string + - name: did + in: path + required: true + schema: + type: string + post: + summary: Client API to activate a DID on the specified Discovery Service. + description: | + An API provided by the discovery client that will cause the given DID to be registered on the specified Discovery Service. + Registration of a Verifiable Presentation will be attempted immediately, and it will be automatically refreshed. + Application only need to call this API once for every service/DID combination, until the registration is explicitly deleted through this API. + + For successful registration on the Discovery Server, the DID's credential wallet must contain the credentials specified by the Discovery Service definition. + If initial registration fails this API returns the error indicating what failed, but will retry at a later moment. + Applications can force a retry by calling this API again. + + error returns: + * 400 - incorrect input: invalid/unknown service or DID. + operationId: activateServiceForDID + tags: + - discovery + responses: + "200": + description: Activation was successful. + "202": + description: Activation was successful, but registration of the Verifiable Presentation failed (but will be automatically re-attempted later). + content: + application/json: + schema: + type: object + required: + - reason + properties: + reason: + type: string + description: Description of why registration failed. + "400": + $ref: "../common/error_response.yaml" + default: + $ref: "../common/error_response.yaml" + delete: + summary: Client API to unregister the given DID from the Discovery Service. + description: | + An API provided by the discovery client that will cause the given DID to be not to be registered any more on the specified Discovery Service. + It will try to delete the existing registration at the Discovery Service, if any. + + error returns: + * 400 - incorrect input: invalid/unknown service or DID. + operationId: deactivateServiceForDID + tags: + - discovery + responses: + "200": + description: | + DID was successfully deactivated from the Discovery Service. + The active Verifiable Presentation was removed from the remote Discovery Server (if applicable). + "202": + description: | + DID was successfully deactivated from the Discovery Service, but failed to remove the active Verifiable Presentation registration from the remote Discovery Server. The registration will be removed by the Discovery Server when the active Verifiable Presentation expires. + Applications might want to retry this API call later, or simply let the presentation expire. + content: + application/json: + schema: + type: object + required: + - reason + properties: + reason: + type: string + description: Description of why removal of the registration failed. + "400": + $ref: "../common/error_response.yaml" + default: + $ref: "../common/error_response.yaml" components: schemas: VerifiablePresentation: diff --git a/docs/diagrams/deployment-diagram.drawio b/docs/diagrams/deployment-diagram.drawio index 927d656c69..84cdaff097 100644 --- a/docs/diagrams/deployment-diagram.drawio +++ b/docs/diagrams/deployment-diagram.drawio @@ -1 +1,437 @@ -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 \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index af50ae1540..3b1f538e0b 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -13,79 +13,79 @@ The following options apply to the server commands below: :: - --auth.accesstokenlifespan int defines how long (in seconds) an access token is valid. Uses default in strict mode. (default 60) - --auth.clockskew int allowed JWT Clock skew in milliseconds (default 5000) - --auth.contractvalidators strings sets the different contract validators to use (default [irma,uzi,dummy,employeeid]) - --auth.http.timeout int HTTP timeout (in seconds) used by the Auth API HTTP client (default 30) - --auth.irma.autoupdateschemas set if you want automatically update the IRMA schemas every 60 minutes. (default true) - --auth.irma.schememanager string IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. (default "pbdf") - --configfile string Nuts config file (default "nuts.yaml") - --cpuprofile string When set, a CPU profile is written to the given path. Ignored when strictmode is set. - --crypto.external.address string Address of the external storage service. - --crypto.external.timeout duration Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). (default 100ms) - --crypto.storage string Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). (default "fs") - --crypto.vault.address string The Vault address. If set it overwrites the VAULT_ADDR env var. - --crypto.vault.pathprefix string The Vault path prefix. (default "kv") - --crypto.vault.timeout duration Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). (default 5s) - --crypto.vault.token string The Vault token. If set it overwrites the VAULT_TOKEN env var. - --datadir string Directory where the node stores its files. (default "./data") - --discovery.definitions.directory string Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. - --discovery.server.definition_ids strings IDs of the Discovery Service Definitions for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. - --events.nats.hostname string Hostname for the NATS server (default "0.0.0.0") - --events.nats.port int Port where the NATS server listens on (default 4222) - --events.nats.storagedir string Directory where file-backed streams are stored in the NATS server - --events.nats.timeout int Timeout for NATS server operations (default 30) - --goldenhammer.enabled Whether to enable automatically fixing DID documents with the required endpoints. (default true) - --goldenhammer.interval duration The interval in which to check for DID documents to fix. (default 10m0s) - --http.default.address string Address and port the server will be listening to (default ":1323") - --http.default.auth.audience string Expected audience for JWT tokens (default: hostname) - --http.default.auth.authorizedkeyspath string Path to an authorized_keys file for trusted JWT signers - --http.default.auth.type string 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 strings When set, enables CORS from the specified origins on the default HTTP interface. - --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', - --httpclient.timeout duration Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. (default 30s) - --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://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.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]) - --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,https://w3id.org/vc/status-list/2021/v1]) - --loggerformat string Log format (text, json) (default "text") - --network.bootstrapnodes strings List of bootstrap nodes (':') which the node initially connect to. - --network.connectiontimeout int Timeout before an outbound connection attempt times out (in milliseconds). (default 5000) - --network.enablediscovery Whether to enable automatic connecting to other nodes. (default true) - --network.enabletls Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. (default true) - --network.grpcaddr string Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). (default ":5555") - --network.maxbackoff duration Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). (default 24h0m0s) - --network.nodedid string Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. - --network.protocols ints Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. - --network.v2.diagnosticsinterval int Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). (default 5000) - --network.v2.gossipinterval int Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. (default 5000) - --pki.maxupdatefailhours int Maximum number of hours that a denylist update can fail (default 4) - --pki.softfail Do not reject certificates if their revocation status cannot be established when softfail is true (default true) - --policy.address string The address of a remote policy server. Mutual exclusive with policy.directory. - --policy.directory string Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. - --storage.bbolt.backup.directory string Target directory for BBolt database backups. - --storage.bbolt.backup.interval duration Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. - --storage.redis.address string Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. - --storage.redis.database string Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. - --storage.redis.password string Redis database password. If set, it overrides the username in the connection URL. - --storage.redis.sentinel.master string Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. - --storage.redis.sentinel.nodes strings Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. - --storage.redis.sentinel.password string Password for authenticating to Redis Sentinels. - --storage.redis.sentinel.username string Username for authenticating to Redis Sentinels. - --storage.redis.tls.truststorefile string PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). - --storage.redis.username string Redis database username. If set, it overrides the username in the connection URL. - --storage.sql.connection string Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). - --strictmode When set, insecure settings are forbidden. (default true) - --tls.certfile string PEM file containing the certificate for the server (also used as client certificate). - --tls.certheader string Name of the HTTP header that will contain the client certificate when TLS is offloaded. - --tls.certkeyfile string PEM file containing the private key of the server certificate. - --tls.offload string Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. - --tls.truststorefile string PEM file containing the trusted CA certificates for authenticating remote servers. (default "truststore.pem") - --url string Public facing URL of the server (required). Must be HTTPS when strictmode is set. - --vcr.openid4vci.definitionsdir string Directory with the additional credential definitions the node could issue (experimental, may change without notice). - --vcr.openid4vci.enabled Enable issuing and receiving credentials over OpenID4VCI. (default true) - --vcr.openid4vci.timeout duration Time-out for OpenID4VCI HTTP client operations. (default 30s) - --verbosity string Log level (trace, debug, info, warn, error) (default "info") + --auth.accesstokenlifespan int defines how long (in seconds) an access token is valid. Uses default in strict mode. (default 60) + --auth.clockskew int allowed JWT Clock skew in milliseconds (default 5000) + --auth.contractvalidators strings sets the different contract validators to use (default [irma,uzi,dummy,employeeid]) + --auth.irma.autoupdateschemas set if you want automatically update the IRMA schemas every 60 minutes. (default true) + --auth.irma.schememanager string IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. (default "pbdf") + --configfile string Nuts config file (default "nuts.yaml") + --cpuprofile string When set, a CPU profile is written to the given path. Ignored when strictmode is set. + --crypto.external.address string Address of the external storage service. + --crypto.external.timeout duration Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). (default 100ms) + --crypto.storage string Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). (default "fs") + --crypto.vault.address string The Vault address. If set it overwrites the VAULT_ADDR env var. + --crypto.vault.pathprefix string The Vault path prefix. (default "kv") + --crypto.vault.timeout duration Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). (default 5s) + --crypto.vault.token string The Vault token. If set it overwrites the VAULT_TOKEN env var. + --datadir string Directory where the node stores its files. (default "./data") + --discovery.client.registration_refresh_interval duration Interval at which the client should refresh checks for registrations to refresh on the configured Discovery Services,in Golang time.Duration string format (e.g. 1s). Note that it only will actually refresh registrations that about to expire (less than 1/4th of their lifetime left). (default 10m0s) + --discovery.definitions.directory string Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. + --discovery.server.definition_ids strings IDs of the Discovery Service Definitions for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. + --events.nats.hostname string Hostname for the NATS server (default "0.0.0.0") + --events.nats.port int Port where the NATS server listens on (default 4222) + --events.nats.storagedir string Directory where file-backed streams are stored in the NATS server + --events.nats.timeout int Timeout for NATS server operations (default 30) + --goldenhammer.enabled Whether to enable automatically fixing DID documents with the required endpoints. (default true) + --goldenhammer.interval duration The interval in which to check for DID documents to fix. (default 10m0s) + --http.default.address string Address and port the server will be listening to (default ":1323") + --http.default.auth.audience string Expected audience for JWT tokens (default: hostname) + --http.default.auth.authorizedkeyspath string Path to an authorized_keys file for trusted JWT signers + --http.default.auth.type string 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 strings When set, enables CORS from the specified origins on the default HTTP interface. + --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', + --httpclient.timeout duration Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. (default 30s) + --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://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.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]) + --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,https://w3id.org/vc/status-list/2021/v1]) + --loggerformat string Log format (text, json) (default "text") + --network.bootstrapnodes strings List of bootstrap nodes (':') which the node initially connect to. + --network.connectiontimeout int Timeout before an outbound connection attempt times out (in milliseconds). (default 5000) + --network.enablediscovery Whether to enable automatic connecting to other nodes. (default true) + --network.enabletls Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. (default true) + --network.grpcaddr string Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). (default ":5555") + --network.maxbackoff duration Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). (default 24h0m0s) + --network.nodedid string Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. + --network.protocols ints Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. + --network.v2.diagnosticsinterval int Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). (default 5000) + --network.v2.gossipinterval int Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. (default 5000) + --pki.maxupdatefailhours int Maximum number of hours that a denylist update can fail (default 4) + --pki.softfail Do not reject certificates if their revocation status cannot be established when softfail is true (default true) + --policy.address string The address of a remote policy server. Mutual exclusive with policy.directory. + --policy.directory string Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. + --storage.bbolt.backup.directory string Target directory for BBolt database backups. + --storage.bbolt.backup.interval duration Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. + --storage.redis.address string Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. + --storage.redis.database string Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. + --storage.redis.password string Redis database password. If set, it overrides the username in the connection URL. + --storage.redis.sentinel.master string Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. + --storage.redis.sentinel.nodes strings Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. + --storage.redis.sentinel.password string Password for authenticating to Redis Sentinels. + --storage.redis.sentinel.username string Username for authenticating to Redis Sentinels. + --storage.redis.tls.truststorefile string PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + --storage.redis.username string Redis database username. If set, it overrides the username in the connection URL. + --storage.sql.connection string Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + --strictmode When set, insecure settings are forbidden. (default true) + --tls.certfile string PEM file containing the certificate for the server (also used as client certificate). + --tls.certheader string Name of the HTTP header that will contain the client certificate when TLS is offloaded. + --tls.certkeyfile string PEM file containing the private key of the server certificate. + --tls.offload string Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. + --tls.truststorefile string PEM file containing the trusted CA certificates for authenticating remote servers. (default "truststore.pem") + --url string Public facing URL of the server (required). Must be HTTPS when strictmode is set. + --vcr.openid4vci.definitionsdir string Directory with the additional credential definitions the node could issue (experimental, may change without notice). + --vcr.openid4vci.enabled Enable issuing and receiving credentials over OpenID4VCI. (default true) + --vcr.openid4vci.timeout duration Time-out for OpenID4VCI HTTP client operations. (default 30s) + --verbosity string Log level (trace, debug, info, warn, error) (default "info") nuts config ^^^^^^^^^^^ diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index deaf098fc0..eceb2a1d1d 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -2,92 +2,92 @@ :widths: 20 30 50 :class: options-table - ==================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ - Key Default Description - ==================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ - configfile nuts.yaml Nuts config file - cpuprofile When set, a CPU profile is written to the given path. Ignored when strictmode is set. - datadir ./data Directory where the node stores its files. - internalratelimiter true When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. - loggerformat text Log format (text, json) - strictmode true When set, insecure settings are forbidden. - url Public facing URL of the server (required). Must be HTTPS when strictmode is set. - verbosity info Log level (trace, debug, info, warn, error) - httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. - tls.certfile PEM file containing the certificate for the server (also used as client certificate). - tls.certheader Name of the HTTP header that will contain the client certificate when TLS is offloaded. - tls.certkeyfile PEM file containing the private key of the server certificate. - tls.offload Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. - tls.truststorefile truststore.pem PEM file containing the trusted CA certificates for authenticating remote servers. - **Auth** - auth.accesstokenlifespan 60 defines how long (in seconds) an access token is valid. Uses default in strict mode. - auth.clockskew 5000 allowed JWT Clock skew in milliseconds - auth.contractvalidators [irma,uzi,dummy,employeeid] sets the different contract validators to use - auth.http.timeout 30 HTTP timeout (in seconds) used by the Auth API HTTP client - auth.irma.autoupdateschemas true set if you want automatically update the IRMA schemas every 60 minutes. - auth.irma.schememanager pbdf IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. - **Crypto** - crypto.storage fs Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). - crypto.external.address Address of the external storage service. - crypto.external.timeout 100ms Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). - crypto.vault.address The Vault address. If set it overwrites the VAULT_ADDR env var. - crypto.vault.pathprefix kv The Vault path prefix. - crypto.vault.timeout 5s Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). - crypto.vault.token The Vault token. If set it overwrites the VAULT_TOKEN env var. - **Discovery** - discovery.definitions.directory Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. - discovery.server.definition_ids [] IDs of the Discovery Service Definitions for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. - **Events** - events.nats.hostname 0.0.0.0 Hostname for the NATS server - events.nats.port 4222 Port where the NATS server listens on - events.nats.storagedir Directory where file-backed streams are stored in the NATS server - events.nats.timeout 30 Timeout for NATS server operations - **GoldenHammer** - goldenhammer.enabled true Whether to enable automatically fixing DID documents with the required endpoints. - goldenhammer.interval 10m0s The interval in which to check for DID documents to fix. - **HTTP** - http.default.address \:1323 Address and port the server will be listening to - http.default.log metadata 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). - http.default.tls Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', - http.default.auth.audience Expected audience for JWT tokens (default: hostname) - http.default.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers - 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,https://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.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,https://w3id.org/vc/status-list/2021/v1] 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. - network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). - network.enablediscovery true Whether to enable automatic connecting to other nodes. - network.enabletls true Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. - network.grpcaddr \:5555 Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). - network.maxbackoff 24h0m0s Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). - network.nodedid Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. - network.protocols [] Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. - network.v2.diagnosticsinterval 5000 Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). - network.v2.gossipinterval 5000 Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. - **PKI** - pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail - pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true - **Storage** - storage.bbolt.backup.directory Target directory for BBolt database backups. - storage.bbolt.backup.interval 0s Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. - storage.redis.address Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. - storage.redis.database Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. - storage.redis.password Redis database password. If set, it overrides the username in the connection URL. - storage.redis.username Redis database username. If set, it overrides the username in the connection URL. - storage.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. - storage.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. - storage.redis.sentinel.password Password for authenticating to Redis Sentinels. - storage.redis.sentinel.username Username for authenticating to Redis Sentinels. - storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). - storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). - **VCR** - vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). - vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. - vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. - **policy** - policy.address The address of a remote policy server. Mutual exclusive with policy.directory. - policy.directory Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. - ==================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ + ============================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ + Key Default Description + ============================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ + configfile nuts.yaml Nuts config file + cpuprofile When set, a CPU profile is written to the given path. Ignored when strictmode is set. + datadir ./data Directory where the node stores its files. + internalratelimiter true When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. + loggerformat text Log format (text, json) + strictmode true When set, insecure settings are forbidden. + url Public facing URL of the server (required). Must be HTTPS when strictmode is set. + verbosity info Log level (trace, debug, info, warn, error) + httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + tls.certfile PEM file containing the certificate for the server (also used as client certificate). + tls.certheader Name of the HTTP header that will contain the client certificate when TLS is offloaded. + tls.certkeyfile PEM file containing the private key of the server certificate. + tls.offload Whether to enable TLS offloading for incoming connections. Enable by setting it to 'incoming'. If enabled 'tls.certheader' must be configured as well. + tls.truststorefile truststore.pem PEM file containing the trusted CA certificates for authenticating remote servers. + **Auth** + auth.accesstokenlifespan 60 defines how long (in seconds) an access token is valid. Uses default in strict mode. + auth.clockskew 5000 allowed JWT Clock skew in milliseconds + auth.contractvalidators [irma,uzi,dummy,employeeid] sets the different contract validators to use + auth.irma.autoupdateschemas true set if you want automatically update the IRMA schemas every 60 minutes. + auth.irma.schememanager pbdf IRMA schemeManager to use for attributes. Can be either 'pbdf' or 'irma-demo'. + **Crypto** + crypto.storage fs Storage to use, 'external' for an external backend (experimental), 'fs' for file system (for development purposes), 'vaultkv' for Vault KV store (recommended, will be replaced by external backend in future). + crypto.external.address Address of the external storage service. + crypto.external.timeout 100ms Time-out when invoking the external storage backend, in Golang time.Duration string format (e.g. 1s). + crypto.vault.address The Vault address. If set it overwrites the VAULT_ADDR env var. + crypto.vault.pathprefix kv The Vault path prefix. + crypto.vault.timeout 5s Timeout of client calls to Vault, in Golang time.Duration string format (e.g. 1s). + crypto.vault.token The Vault token. If set it overwrites the VAULT_TOKEN env var. + **Discovery** + discovery.client.registration_refresh_interval 10m0s Interval at which the client should refresh checks for registrations to refresh on the configured Discovery Services,in Golang time.Duration string format (e.g. 1s). Note that it only will actually refresh registrations that about to expire (less than 1/4th of their lifetime left). + discovery.definitions.directory Directory to load Discovery Service Definitions from. If not set, the discovery service will be disabled. If the directory contains JSON files that can't be parsed as service definition, the node will fail to start. + discovery.server.definition_ids [] IDs of the Discovery Service Definitions for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. + **Events** + events.nats.hostname 0.0.0.0 Hostname for the NATS server + events.nats.port 4222 Port where the NATS server listens on + events.nats.storagedir Directory where file-backed streams are stored in the NATS server + events.nats.timeout 30 Timeout for NATS server operations + **GoldenHammer** + goldenhammer.enabled true Whether to enable automatically fixing DID documents with the required endpoints. + goldenhammer.interval 10m0s The interval in which to check for DID documents to fix. + **HTTP** + http.default.address \:1323 Address and port the server will be listening to + http.default.log metadata 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). + http.default.tls Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', + http.default.auth.audience Expected audience for JWT tokens (default: hostname) + http.default.auth.authorizedkeyspath Path to an authorized_keys file for trusted JWT signers + 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://w3id.org/vc/status-list/2021/v1=assets/contexts/w3c-statuslist2021.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,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.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json,https://w3id.org/vc/status-list/2021/v1] 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. + network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). + network.enablediscovery true Whether to enable automatic connecting to other nodes. + network.enabletls true Whether to enable TLS for gRPC connections, which can be disabled for demo/development purposes. It is NOT meant for TLS offloading (see 'tls.offload'). Disabling TLS is not allowed in strict-mode. + network.grpcaddr \:5555 Local address for gRPC to listen on. If empty the gRPC server won't be started and other nodes will not be able to connect to this node (outbound connections can still be made). + network.maxbackoff 24h0m0s Maximum between outbound connections attempts to unresponsive nodes (in Golang duration format, e.g. '1h', '30m'). + network.nodedid Specifies the DID of the organization that operates this node, typically a vendor for EPD software. It is used to identify the node on the network. If the DID document does not exist of is deactivated, the node will not start. + network.protocols [] Specifies the list of network protocols to enable on the server. They are specified by version (1, 2). If not set, all protocols are enabled. + network.v2.diagnosticsinterval 5000 Interval (in milliseconds) that specifies how often the node should broadcast its diagnostic information to other nodes (specify 0 to disable). + network.v2.gossipinterval 5000 Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. + **PKI** + pki.maxupdatefailhours 4 Maximum number of hours that a denylist update can fail + pki.softfail true Do not reject certificates if their revocation status cannot be established when softfail is true + **Storage** + storage.bbolt.backup.directory Target directory for BBolt database backups. + storage.bbolt.backup.interval 0s Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. + storage.redis.address Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. + storage.redis.database Redis database name, which is used as prefix every key. Can be used to have multiple instances use the same Redis instance. + storage.redis.password Redis database password. If set, it overrides the username in the connection URL. + storage.redis.username Redis database username. If set, it overrides the username in the connection URL. + storage.redis.sentinel.master Name of the Redis Sentinel master. Setting this property enables Redis Sentinel. + storage.redis.sentinel.nodes [] Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel. + storage.redis.sentinel.password Password for authenticating to Redis Sentinels. + storage.redis.sentinel.username Username for authenticating to Redis Sentinels. + storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + storage.sql.connection Connection string for the SQL database. If not set it, defaults to a SQLite database stored inside the configured data directory. Note: using SQLite is not recommended in production environments. If using SQLite anyways, remember to enable foreign keys ('_foreign_keys=on') and the write-ahead-log ('_journal_mode=WAL'). + **VCR** + vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). + vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. + vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. + **policy** + policy.address The address of a remote policy server. Mutual exclusive with policy.directory. + policy.directory Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. + ============================================== ================================================================================================================================================================================================================================================================================================================================================================================================= ================================================================================================================================================================================================================================================================================================================================ diff --git a/e2e-tests/discovery/definitions/definition.json b/e2e-tests/discovery/definitions/definition.json new file mode 100644 index 0000000000..2780260b7a --- /dev/null +++ b/e2e-tests/discovery/definitions/definition.json @@ -0,0 +1,52 @@ +{ + "id": "dev:eOverdracht2023", + "endpoint": "http://nodeA:1323/discovery/dev:eOverdracht2023", + "presentation_max_validity": 2764800, + "presentation_definition": { + "id": "dev:eOverdracht2023", + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + }, + "jwt_vp": { + "alg": ["ES256"] + } + }, + "input_descriptors": [ + { + "id": "SelfIssued_NutsOrganizationCredential", + "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/e2e-tests/discovery/docker-compose.yml b/e2e-tests/discovery/docker-compose.yml new file mode 100644 index 0000000000..351917e539 --- /dev/null +++ b/e2e-tests/discovery/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.7" +services: + nodeA: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" + ports: + - "11323:1323" + - "443" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + volumes: + - "./node-A/nuts.yaml:/opt/nuts/nuts.yaml:ro" + - "./node-A/data:/opt/nuts/data:rw" + - "../tls-certs/nodeA-backend-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + - "../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "./definitions/:/opt/nuts/definitions:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}" + ports: + - "21323:1323" + - "443" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + volumes: + - "./node-B/data:/opt/nuts/data:rw" + - "./node-B/nuts.yaml:/opt/nuts/nuts.yaml:ro" + - "../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + - "../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "./definitions/:/opt/nuts/definitions:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often diff --git a/e2e-tests/discovery/node-A/nuts.yaml b/e2e-tests/discovery/node-A/nuts.yaml new file mode 100644 index 0000000000..b0e0ddde74 --- /dev/null +++ b/e2e-tests/discovery/node-A/nuts.yaml @@ -0,0 +1,23 @@ +url: http://nodeA +verbosity: debug +strictmode: false +internalratelimiter: false +datadir: /opt/nuts/data +http: + default: + address: :1323 +discovery: + definitions: + directory: /opt/nuts/definitions + server: + definition_ids: + - dev:eOverdracht2023 +auth: + contractvalidators: + - dummy + irma: + autoupdateschemas: false +tls: + truststorefile: /opt/nuts/truststore.pem + certfile: /opt/nuts/certificate-and-key.pem + certkeyfile: /opt/nuts/certificate-and-key.pem diff --git a/e2e-tests/discovery/node-B/nuts.yaml b/e2e-tests/discovery/node-B/nuts.yaml new file mode 100644 index 0000000000..878515c03b --- /dev/null +++ b/e2e-tests/discovery/node-B/nuts.yaml @@ -0,0 +1,25 @@ +url: https://nodeB +verbosity: debug +strictmode: false +internalratelimiter: false +datadir: /opt/nuts/data +http: + default: + address: :1323 + alt: + iam: + tls: server + address: :443 +discovery: + definitions: + directory: /opt/nuts/definitions +auth: + v2apienabled: true + contractvalidators: + - dummy + irma: + autoupdateschemas: false +tls: + truststorefile: /opt/nuts/truststore.pem + certfile: /opt/nuts/certificate-and-key.pem + certkeyfile: /opt/nuts/certificate-and-key.pem diff --git a/e2e-tests/discovery/run-test.sh b/e2e-tests/discovery/run-test.sh new file mode 100755 index 0000000000..55eacc723a --- /dev/null +++ b/e2e-tests/discovery/run-test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +source ../util.sh + +# This test asserts the following: +# - Clients update the Discovery Service +# - Clients can register presentations on the Discovery Service +# - When a presentation can't be registered on the Discovery Service, the client will retry +# - Clients can find presentations on the Discovery Service + +echo "------------------------------------" +echo "Cleaning up running Docker containers and volumes, and key material..." +echo "------------------------------------" +docker compose down +docker compose rm -f -v +rm -rf ./node-*/data +mkdir ./node-A/data ./node-B/data # 'data' dirs will be created with root owner by docker if they do not exit. This creates permission issues on CI. + +echo "------------------------------------" +echo "Starting Docker containers..." +echo "------------------------------------" +docker compose up --wait nodeB + +echo "------------------------------------" +echo "Registering care organization..." +echo "------------------------------------" +DIDDOC=$(docker compose exec nodeB nuts vdr create-did --v2) +DID=$(echo $DIDDOC | jq -r .id) +echo DID: $DID + +REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${DID}\", \"credentialSubject\": {\"id\":\"${DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"publishToNetwork\": false}" +RESPONSE=$(echo $REQUEST | curl --insecure -s -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") +if echo $RESPONSE | grep -q "VerifiableCredential"; then + echo "VC issued" +else + echo "FAILED: Could not issue NutsOrganizationCredential" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +RESPONSE=$(echo $RESPONSE | curl --insecure -s -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/holder/${DID}/vc -H "Content-Type:application/json") +if [$RESPONSE -eq ""]; then + echo "VC stored in wallet" +else + echo "FAILED: Could not load NutsOrganizationCredential" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Registering care organization on Discovery Service..." +echo "---------------------------------------" +curl --insecure -s -X POST http://localhost:21323/internal/discovery/v1/dev:eOverdracht2023/${DID} + +echo "---------------------------------------" +echo "Restarting to force registration on Discovery Service..." +echo "---------------------------------------" +docker compose up --wait nodeA +docker compose down nodeB +docker compose up --wait nodeB + +echo "---------------------------------------" +echo "Searching for care organization registration..." +echo "---------------------------------------" +echo "TODO: Requires clients updating discovery service and search API (https://github.com/nuts-foundation/nuts-node/pull/2672)" + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose stop diff --git a/e2e-tests/discovery/run-tests.sh b/e2e-tests/discovery/run-tests.sh new file mode 100755 index 0000000000..c6f452f2ba --- /dev/null +++ b/e2e-tests/discovery/run-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e # make script fail if any of the tests returns a non-zero exit code + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!! Running test: Discovery !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +./run-test.sh \ No newline at end of file diff --git a/e2e-tests/run-tests.sh b/e2e-tests/run-tests.sh index 27db218605..1d110f798f 100755 --- a/e2e-tests/run-tests.sh +++ b/e2e-tests/run-tests.sh @@ -50,3 +50,10 @@ echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" pushd denylist ./run-tests.sh popd + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!! Running test suite: Discovery !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +pushd discovery +./run-tests.sh +popd diff --git a/storage/engine_test.go b/storage/engine_test.go index aa27b500a1..ab91e89e08 100644 --- a/storage/engine_test.go +++ b/storage/engine_test.go @@ -143,6 +143,6 @@ func Test_engine_sqlDatabase(t *testing.T) { require.NoError(t, row.Err()) var count int assert.NoError(t, row.Scan(&count)) - assert.Equal(t, 2, count) + assert.Equal(t, 3, count) }) } diff --git a/storage/sql_migrations/003_discoveryservice_client_registration.sql b/storage/sql_migrations/003_discoveryservice_client_registration.sql new file mode 100644 index 0000000000..2dcc55eea2 --- /dev/null +++ b/storage/sql_migrations/003_discoveryservice_client_registration.sql @@ -0,0 +1,20 @@ +-- migrate:up +-- discovery_did_registration contains the DIDs that should be registered on the specified Discovery Service(s). +create table discovery_presentation_refresh +( + -- service_id is the ID of the Discover Service that the DID should be registered on. + -- It comes from the service definition. + service_id varchar(200) not null, + -- did is the DID that should be registered on the Discovery Service. + did varchar(500) not null, + -- next_refresh is the timestamp (seconds since Unix epoch) when the registration on the + -- Discovery Service should be refreshed. + next_refresh integer not null, + primary key (service_id, did), + constraint fk_discovery_presentation_refresh_service foreign key (service_id) references discovery_service (id) on delete cascade +); +-- index for the next_registration column, used when checking which registrations need to be refreshed +create index idx_discovery_presentation_refresh on discovery_presentation_refresh (next_refresh); + +-- migrate:down +drop table discovery_presentation_refresh; diff --git a/vcr/holder/wallet.go b/vcr/holder/wallet.go index e383235c5d..af03222ada 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -138,6 +138,9 @@ func (h wallet) buildJWTPresentation(ctx context.Context, subjectDID did.DID, cr if options.ProofOptions.Expires != nil { claims[jwt.ExpirationKey] = int(options.ProofOptions.Expires.Unix()) } + for claimName, value := range options.ProofOptions.AdditionalProperties { + claims[claimName] = value + } token, err := h.keyStore.SignJWT(ctx, claims, headers, key) if err != nil { return nil, fmt.Errorf("unable to sign JWT presentation: %w", err) diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index 0401ec82b9..0604267256 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -192,6 +192,9 @@ func TestWallet_BuildPresentation(t *testing.T) { Created: exp.Add(-1 * time.Hour), Domain: &domain, Nonce: &nonce, + AdditionalProperties: map[string]interface{}{ + "custom": "claim", + }, }, } @@ -213,6 +216,8 @@ func TestWallet_BuildPresentation(t *testing.T) { assert.Equal(t, []string{domain}, result.JWT().Audience()) actualNonce, _ := result.JWT().Get("nonce") assert.Equal(t, nonce, actualNonce) + actualCustomClaim, _ := result.JWT().Get("custom") + assert.Equal(t, "claim", actualCustomClaim) }) }) t.Run("validation", func(t *testing.T) { diff --git a/vcr/signature/proof/jsonld.go b/vcr/signature/proof/jsonld.go index f4914b9c00..ccf0d412ff 100644 --- a/vcr/signature/proof/jsonld.go +++ b/vcr/signature/proof/jsonld.go @@ -64,6 +64,9 @@ type ProofOptions struct { ProofPurpose string `json:"proofPurpose"` // Nonce contains a value that is used to prevent replay attacks Nonce *string `json:"nonce,omitempty"` + // AdditionalProperties is used to specify additional, non-standard properties. + // They are included as JWT claims in jwt_vp proof format, all other formats ignore them (for now). + AdditionalProperties map[string]interface{} `json:"-"` } // ValidAt checks if the proof is valid at a certain given time.