diff --git a/.circleci/config.yml b/.circleci/config.yml index 2a4e5c7575..202fc79b7c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ jobs: build: parallelism: 8 docker: - - image: cimg/go:1.22 + - image: cimg/go:1.23 steps: - checkout @@ -36,7 +36,7 @@ jobs: report: docker: - - image: cimg/go:1.22 + - image: cimg/go:1.23 steps: - checkout - attach_workspace: diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..56c6f062ca --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @woutslakhorst @reinkrul @gerardsn @stevenvegt diff --git a/.github/workflows/codeql-analysis-cron-schedule.yml b/.github/workflows/codeql-analysis-cron-schedule.yml new file mode 100644 index 0000000000..c3fe7e0edf --- /dev/null +++ b/.github/workflows/codeql-analysis-cron-schedule.yml @@ -0,0 +1,69 @@ +# This is an alternative to the codeql-analysis.yml that only contains a scheduled evaluation of CodeQL +# The action runs for all branches defined in jobs.analyze.strategy.matrix.branches. +# Every new production branch (minor release branches) should be added to this list. + +name: "Scheduled CodeQL" + +# run twice a week at a random time on Sunday and Wednesday evening so its available the next morning +on: + schedule: + - cron: '42 21 * * 0,3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + # CodeQL runs on these branches + branches: + - 'master' + - 'V5.4' + - 'V6.0' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ matrix.branches }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + # use go version from go.mod. + go-version-file: 'go.mod' + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: 'go' + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4cf60cc8f2..51e6a54af4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,8 +21,6 @@ on: branches: - 'master' - 'V*' - schedule: - - cron: '21 10 * * 2' jobs: analyze: diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index e77e48599f..91ecbd7d38 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -75,7 +75,7 @@ jobs: - name: Run E2E tests run: | cd e2e-tests && \ - find . -type f -name "docker-compose.yml" | xargs -I{} sed -i 's~nutsfoundation/nuts-node:master~ghcr.io/nuts-foundation/nuts-node-ci:${{ env.SHA }}~g' {} && \ + find . -type f -name "docker-compose*.yml" | xargs -I{} sed -i 's~nutsfoundation/nuts-node:master~ghcr.io/nuts-foundation/nuts-node-ci:${{ env.SHA }}~g' {} && \ find . -type f -name "run-test.sh" | xargs -I{} sed -i 's/docker-compose exec/docker-compose exec -T/g' {} && \ ./run-tests.sh diff --git a/Dockerfile b/Dockerfile index 8c868f07a7..55ef226f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # golang alpine -FROM golang:1.23.1-alpine AS builder +FROM golang:1.23.2-alpine AS builder ARG TARGETARCH ARG TARGETOS diff --git a/README.rst b/README.rst index b626832715..14908033ac 100644 --- a/README.rst +++ b/README.rst @@ -173,6 +173,7 @@ The following options can be configured on the server: configfile ./config/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. + didmethods [web,nuts] Comma-separated list of enabled DID methods (without did: prefix). It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used. 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. @@ -196,6 +197,7 @@ The following options can be configured on the server: discovery.definitions.directory ./config/discovery 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.ids [] IDs of the Discovery Service for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. **HTTP** + http.clientipheader X-Forwarded-For Case-sensitive HTTP Header that contains the client IP used for audit logs. For the X-Forwarded-For header only link-local, loopback, and private IPs are excluded. Switch to X-Real-IP or a custom header if you see your own proxy/infra in the logs. http.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). When debug vebosity is set the authorization headers are also logged when the request is fully logged. http.cache.maxbytes 10485760 HTTP client maximum size of the response cache in bytes. If 0, the HTTP client does not cache responses. http.internal.address 127.0.0.1:8081 Address and port the server will be listening to for internal-facing endpoints. @@ -216,8 +218,6 @@ The following options can be configured on the server: storage.session.redis.username Redis session database username. If set, it overrides the username in the connection URL. storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session 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'). - **VDR** - vdr.didmethods [web,nuts] Comma-separated list of enabled DID methods (without did: prefix). It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ @@ -301,8 +301,8 @@ Several of the server options above allow the node to be configured in a way tha The node can be configured to run in strict mode (default) to prevent any insecure configurations. Below is a summary of the impact ``strictmode=true`` has on the node and its configuration. -Save storage of any private key material requires some serious consideration. -For this reason the ``crypto.storage`` backend must explicitly be set. +Save storage of any private key material and data requires some serious consideration. +For this reason the ``crypto.storage`` backend and the ``storage.sql.connection`` connection string must explicitly be set. Private transactions can only be exchanged over authenticated nodes. Therefore is requires TLS to be configured through ``tls.{certfile,certkeyfile,truststore}``. diff --git a/api/generated.go b/api/generated.go index 223ba5662f..34eabac445 100644 --- a/api/generated.go +++ b/api/generated.go @@ -1,6 +1,6 @@ // Package ssiTypes provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package ssiTypes import ( diff --git a/auth/api/auth/v1/api.go b/auth/api/auth/v1/api.go index a608669f1b..0d79c9d4a5 100644 --- a/auth/api/auth/v1/api.go +++ b/auth/api/auth/v1/api.go @@ -271,7 +271,7 @@ func (w Wrapper) CreateJwtGrant(ctx context.Context, request CreateJwtGrantReque response, err := w.Auth.RelyingParty().CreateJwtGrant(ctx, req) if err != nil { - return nil, core.InvalidInputError(err.Error()) + return nil, core.InvalidInputError("%w", err) } return CreateJwtGrant200JSONResponse{BearerToken: response.BearerToken, AuthorizationServerEndpoint: response.AuthorizationServerEndpoint}, nil @@ -289,7 +289,7 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo jwtGrant, err := w.Auth.RelyingParty().CreateJwtGrant(ctx, req) if err != nil { - return nil, core.InvalidInputError(err.Error()) + return nil, core.InvalidInputError("%w", err) } authServerEndpoint, err := url.Parse(jwtGrant.AuthorizationServerEndpoint) @@ -299,7 +299,7 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo accessTokenResult, err := w.Auth.RelyingParty().RequestRFC003AccessToken(ctx, jwtGrant.BearerToken, *authServerEndpoint) if err != nil { - return nil, core.Error(http.StatusServiceUnavailable, err.Error()) + return nil, core.Error(http.StatusServiceUnavailable, "%w", err) } return RequestAccessToken200JSONResponse(*accessTokenResult), nil } @@ -401,7 +401,7 @@ func (w Wrapper) IntrospectAccessToken(ctx context.Context, request IntrospectAc introspectionResponse.AssuranceLevel = &level } - if claims.Credentials != nil && len(claims.Credentials) > 0 { + if len(claims.Credentials) > 0 { introspectionResponse.Vcs = &claims.Credentials var resolvedVCs []VerifiableCredential diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 14e49d5d64..cb783e904a 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -61,11 +61,12 @@ type TestContext struct { var _ pkg2.AuthenticationServices = &mockAuthClient{} type mockAuthClient struct { - ctrl *gomock.Controller - authzServer *oauth.MockAuthorizationServer - contractNotary *services.MockContractNotary - iamClient *iam.MockClient - relyingParty *oauth.MockRelyingParty + ctrl *gomock.Controller + authzServer *oauth.MockAuthorizationServer + contractNotary *services.MockContractNotary + iamClient *iam.MockClient + relyingParty *oauth.MockRelyingParty + supportedDIDMethods []string } func (m *mockAuthClient) AuthorizationEndpointEnabled() bool { @@ -92,6 +93,10 @@ func (m *mockAuthClient) PublicURL() *url.URL { return nil } +func (m *mockAuthClient) SupportedDIDMethods() []string { + return m.supportedDIDMethods +} + func createContext(t *testing.T) *TestContext { t.Helper() ctrl := gomock.NewController(t) @@ -102,11 +107,12 @@ func createContext(t *testing.T) *TestContext { iamClient := iam.NewMockClient(ctrl) authMock := &mockAuthClient{ - ctrl: ctrl, - contractNotary: contractNotary, - authzServer: authzServer, - relyingParty: relyingParty, - iamClient: iamClient, + ctrl: ctrl, + contractNotary: contractNotary, + authzServer: authzServer, + relyingParty: relyingParty, + iamClient: iamClient, + supportedDIDMethods: []string{"web", "nuts"}, } requestCtx := audit.TestContext() diff --git a/auth/api/auth/v1/client/generated.go b/auth/api/auth/v1/client/generated.go index 37129ee5ff..ea4d4a745a 100644 --- a/auth/api/auth/v1/client/generated.go +++ b/auth/api/auth/v1/client/generated.go @@ -1,6 +1,6 @@ // Package client provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package client import ( diff --git a/auth/api/auth/v1/generated.go b/auth/api/auth/v1/generated.go index 6b0cd50878..df2b8cefb4 100644 --- a/auth/api/auth/v1/generated.go +++ b/auth/api/auth/v1/generated.go @@ -1,6 +1,6 @@ // Package v1 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v1 import ( diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index b3f61b1569..1335973fbc 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -27,33 +27,34 @@ import ( "encoding/json" "errors" "fmt" - "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/nuts-foundation/nuts-node/http/cache" - "github.com/nuts-foundation/nuts-node/http/user" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" "html/template" "net/http" "net/url" + "slices" "strings" "time" + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/api/iam/assets" + iamclient "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/http/cache" + "github.com/nuts-foundation/nuts-node/http/user" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/pe" - "github.com/nuts-foundation/nuts-node/vdr" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -97,7 +98,6 @@ type Wrapper struct { storageEngine storage.Engine jsonldManager jsonld.JSONLD vcr vcr.VCR - vdr vdr.VDR jwtSigner nutsCrypto.JWTSigner keyResolver resolver.KeyResolver subjectManager didsubject.Manager @@ -105,7 +105,7 @@ type Wrapper struct { } func New( - authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, subjectManager didsubject.Manager, storageEngine storage.Engine, + authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, didKeyResolver resolver.DIDKeyResolver, subjectManager didsubject.Manager, storageEngine storage.Engine, policyBackend policy.PDPBackend, jwtSigner nutsCrypto.JWTSigner, jsonldManager jsonld.JSONLD) *Wrapper { templates := template.New("oauth2 templates") @@ -113,21 +113,19 @@ func New( if err != nil { panic(err) } - keyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()} return &Wrapper{ auth: authInstance, policyBackend: policyBackend, storageEngine: storageEngine, vcr: vcrInstance, - vdr: vdrInstance, subjectManager: subjectManager, jsonldManager: jsonldManager, jwtSigner: jwtSigner, - keyResolver: keyResolver, + keyResolver: didKeyResolver, jar: jar{ auth: authInstance, jwtSigner: jwtSigner, - keyResolver: keyResolver, + keyResolver: didKeyResolver, }, } } @@ -202,6 +200,9 @@ func (r Wrapper) ResolveStatusCode(err error) int { resolver.ErrDIDNotManagedByThisNode: http.StatusBadRequest, pe.ErrNoCredentials: http.StatusPreconditionFailed, didsubject.ErrSubjectNotFound: http.StatusNotFound, + iamclient.ErrInvalidClientCall: http.StatusBadRequest, + iamclient.ErrBadGateway: http.StatusBadGateway, + iamclient.ErrPreconditionFailed: http.StatusPreconditionFailed, }) } @@ -335,7 +336,11 @@ func (r Wrapper) RetrieveAccessToken(_ context.Context, request RetrieveAccessTo } // IntrospectAccessToken allows the resource server (XIS/EHR) to introspect details of an access token issued by this node -func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) { +func (r Wrapper) IntrospectAccessToken(ctx context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) { + headers := ctx.Value(httpRequestContextKey{}).(*http.Request).Header + if !slices.Contains(headers["Content-Type"], "application/x-www-form-urlencoded") { + return nil, core.Error(http.StatusUnsupportedMediaType, "Content-Type MUST be set to application/x-www-form-urlencoded") + } input := request.Body.Token response, err := r.introspectAccessToken(input) if err != nil { @@ -351,7 +356,11 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce // IntrospectAccessTokenExtended allows the resource server (XIS/EHR) to introspect details of an access token issued by this node. // It returns the same information as IntrospectAccessToken, but with additional information. -func (r Wrapper) IntrospectAccessTokenExtended(_ context.Context, request IntrospectAccessTokenExtendedRequestObject) (IntrospectAccessTokenExtendedResponseObject, error) { +func (r Wrapper) IntrospectAccessTokenExtended(ctx context.Context, request IntrospectAccessTokenExtendedRequestObject) (IntrospectAccessTokenExtendedResponseObject, error) { + headers := ctx.Value(httpRequestContextKey{}).(*http.Request).Header + if !slices.Contains(headers["Content-Type"], "application/x-www-form-urlencoded") { + return nil, core.Error(http.StatusUnsupportedMediaType, "Content-Type MUST be set to application/x-www-form-urlencoded") + } input := request.Body.Token response, err := r.introspectAccessToken(input) if err != nil { @@ -366,7 +375,7 @@ func (r Wrapper) introspectAccessToken(input string) (*ExtendedTokenIntrospectio // Validate token if input == "" { // Return 200 + 'Active = false' when token is invalid or malformed - log.Logger().Debug("IntrospectAccessToken: missing token") + log.Logger().Warn("IntrospectAccessToken: missing token") return nil, nil } @@ -599,7 +608,7 @@ func (r Wrapper) OAuthAuthorizationServerMetadata(_ context.Context, request OAu } func (r Wrapper) oauthAuthorizationServerMetadata(clientID url.URL) (*oauth.AuthorizationServerMetadata, error) { - md := authorizationServerMetadata(&clientID, r.vdr.SupportedMethods()) + md := authorizationServerMetadata(&clientID, r.auth.SupportedDIDMethods()) if !r.auth.AuthorizationEndpointEnabled() { md.AuthorizationEndpoint = "" } @@ -663,7 +672,7 @@ func (r Wrapper) OpenIDConfiguration(ctx context.Context, request OpenIDConfigur // this is a shortcoming of the openID federation vs OpenID4VP/DID worlds // issuer URL equals server baseURL + :/oauth2/:subject issuerURL := r.subjectToBaseURL(request.SubjectID) - configuration := openIDConfiguration(issuerURL, set, r.vdr.SupportedMethods()) + configuration := openIDConfiguration(issuerURL, set, r.auth.SupportedDIDMethods()) claims := make(map[string]interface{}) asJson, _ := json.Marshal(configuration) _ = json.Unmarshal(asJson, &claims) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 8dbdb2012e..8e3ae8dbc2 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -626,21 +626,24 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { ctx := newTestClient(t) dpopToken, _, thumbprint := newSignedTestDPoP() + req := http.Request{Header: map[string][]string{"Content-Type": {"application/x-www-form-urlencoded"}}} + reqCtx := context.WithValue(context.Background(), httpRequestContextKey{}, &req) + // validate all fields are there after introspection t.Run("error - no token provided", func(t *testing.T) { - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: ""}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: ""}}) require.NoError(t, err) assert.Equal(t, res, IntrospectAccessToken200JSONResponse{}) }) t.Run("error - other store error", func(t *testing.T) { // token is invalid JSON require.NoError(t, ctx.client.accessTokenServerStore().Put("err", "{")) - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "err"}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "err"}}) assert.ErrorContains(t, err, "json: cannot unmarshal") assert.Nil(t, res) }) t.Run("error - does not exist", func(t *testing.T) { - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "does not exist"}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "does not exist"}}) require.NoError(t, err) assert.Equal(t, res, IntrospectAccessToken200JSONResponse{}) }) @@ -648,7 +651,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { token := AccessToken{Expiration: time.Now().Add(-time.Second)} require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) require.NoError(t, err) assert.Equal(t, res, IntrospectAccessToken200JSONResponse{}) @@ -670,7 +673,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { token := okToken require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) require.NoError(t, err) tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) @@ -684,7 +687,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { token := okToken require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) - res, err := ctx.client.IntrospectAccessTokenExtended(context.Background(), IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + res, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) require.NoError(t, err) tokenResponse, ok := res.(IntrospectAccessTokenExtended200JSONResponse) @@ -703,7 +706,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { } require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) require.NoError(t, err) tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) @@ -719,7 +722,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { } require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) - res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) require.EqualError(t, err, "IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name: iss") require.Nil(t, res) @@ -767,13 +770,26 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { }) require.NoError(t, err) - res, err := ctx.client.IntrospectAccessTokenExtended(context.Background(), IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: token.Token}}) + res, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: token.Token}}) require.NoError(t, err) tokenResponse, err := json.Marshal(res) assert.NoError(t, err) assert.JSONEq(t, string(expectedResponse), string(tokenResponse)) }) + t.Run("error - wrong Content-Type header", func(t *testing.T) { + req := http.Request{Header: map[string][]string{"Content-Type": {"something-else"}}} + reqCtx := context.WithValue(context.Background(), httpRequestContextKey{}, &req) + expectedErr := core.Error(http.StatusUnsupportedMediaType, "Content-Type MUST be set to application/x-www-form-urlencoded") + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "not-empty"}}) + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, res) + + resExt, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "not-empty"}}) + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, resExt) + }) } func TestWrapper_Routes(t *testing.T) { @@ -1419,7 +1435,6 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b vcIssuer := issuer.NewMockIssuer(ctrl) vcVerifier := verifier.NewMockVerifier(ctrl) iamClient := iam.NewMockClient(ctrl) - mockVDR := vdr.NewMockVDR(ctrl) mockDocumentOwner := didsubject.NewMockDocumentOwner(ctrl) subjectManager := didsubject.NewMockManager(ctrl) mockVCR := vcr.NewMockVCR(ctrl) @@ -1430,14 +1445,12 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes() + authnServices.EXPECT().SupportedDIDMethods().Return([]string{"web"}).AnyTimes() mockVCR.EXPECT().Issuer().Return(vcIssuer).AnyTimes() mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes() mockVCR.EXPECT().Wallet().Return(mockWallet).AnyTimes() authnServices.EXPECT().IAMClient().Return(iamClient).AnyTimes() authnServices.EXPECT().AuthorizationEndpointEnabled().Return(authEndpointEnabled).AnyTimes() - mockVDR.EXPECT().Resolver().Return(mockResolver).AnyTimes() - mockVDR.EXPECT().DocumentOwner().Return(mockDocumentOwner).AnyTimes() - mockVDR.EXPECT().SupportedMethods().Return([]string{"web"}).AnyTimes() subjectManager.EXPECT().ListDIDs(gomock.Any(), holderSubjectID).Return([]did.DID{holderDID}, nil).AnyTimes() subjectManager.EXPECT().ListDIDs(gomock.Any(), unknownSubjectID).Return(nil, didsubject.ErrSubjectNotFound).AnyTimes() @@ -1449,7 +1462,6 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b client := &Wrapper{ auth: authnServices, - vdr: mockVDR, subjectManager: subjectManager, vcr: mockVCR, storageEngine: storageEngine, @@ -1466,7 +1478,6 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b vcIssuer: vcIssuer, vcVerifier: vcVerifier, resolver: mockResolver, - vdr: mockVDR, documentOwner: mockDocumentOwner, subjectManager: subjectManager, iamClient: iamClient, diff --git a/auth/api/iam/dpop.go b/auth/api/iam/dpop.go index 1e8e7ef120..6de6e379bb 100644 --- a/auth/api/iam/dpop.go +++ b/auth/api/iam/dpop.go @@ -49,7 +49,7 @@ func (r Wrapper) CreateDPoPProof(ctx context.Context, request CreateDPoPProofReq // create new DPoP header httpRequest, err := http.NewRequest(request.Body.Htm, request.Body.Htu, nil) if err != nil { - return nil, core.InvalidInputError(err.Error()) + return nil, core.InvalidInputError("%w", err) } token := dpop.New(*httpRequest) token.GenerateProof(request.Body.Token) @@ -58,7 +58,7 @@ func (r Wrapper) CreateDPoPProof(ctx context.Context, request CreateDPoPProofReq // unescape manually here kid, err := url.PathUnescape(request.Kid) if err != nil { - return nil, core.InvalidInputError(err.Error()) + return nil, core.InvalidInputError("%w", err) } dpop, err := r.jwtSigner.SignDPoP(ctx, *token, kid) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 8ab724b9ee..c8135c523e 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -1,6 +1,6 @@ // Package iam provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package iam import ( @@ -84,7 +84,7 @@ type ExtendedTokenIntrospectionResponse struct { // Aud RFC7662 - Service-specific string identifier or list of string identifiers representing the intended audience for this token, as defined in JWT [RFC7519]. Aud *string `json:"aud,omitempty"` - // ClientId The client (DID) the access token was issued to + // ClientId The client identity the access token was issued to. Since the Verifiable Presentation is used to grant access, the client_id reflects the client_id in the access token request. ClientId *string `json:"client_id,omitempty"` // Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. @@ -96,7 +96,7 @@ type ExtendedTokenIntrospectionResponse struct { // Iat Issuance time in seconds since UNIX epoch Iat *int `json:"iat,omitempty"` - // Iss Contains the DID of the authorizer. Should be equal to 'sub' + // Iss Issuer URL of the authorizer. Iss *string `json:"iss,omitempty"` // PresentationDefinitions Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token @@ -1526,22 +1526,13 @@ func (response PresentationDefinition200JSONResponse) VisitPresentationDefinitio return json.NewEncoder(w).Encode(response) } -type PresentationDefinitiondefaultApplicationProblemPlusJSONResponse 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"` - } +type PresentationDefinitiondefaultJSONResponse struct { + Body ErrorResponse StatusCode int } -func (response PresentationDefinitiondefaultApplicationProblemPlusJSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") +func (response PresentationDefinitiondefaultJSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(response.StatusCode) return json.NewEncoder(w).Encode(response.Body) diff --git a/auth/api/iam/jar.go b/auth/api/iam/jar.go index 8c98ebeb94..8660a86b27 100644 --- a/auth/api/iam/jar.go +++ b/auth/api/iam/jar.go @@ -196,7 +196,7 @@ func compareThumbprint(configurationKey jwk.Key, publicKey crypto.PublicKey) err if err != nil { return err } - if bytes.Compare(thumbprintLeft, thumbprintRight) != 0 { + if !bytes.Equal(thumbprintLeft, thumbprintRight) { return errors.New("key thumbprints do not match") } return nil diff --git a/auth/api/iam/jar_mock.go b/auth/api/iam/jar_mock.go index 0d5f3ffb50..a048b78b4e 100644 --- a/auth/api/iam/jar_mock.go +++ b/auth/api/iam/jar_mock.go @@ -23,6 +23,7 @@ import ( type MockJAR struct { ctrl *gomock.Controller recorder *MockJARMockRecorder + isgomock struct{} } // MockJARMockRecorder is the mock recorder for MockJAR. diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 62e32a3c89..405a52e7b3 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -384,7 +384,7 @@ func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, subject string, vp // Dispatch a new HTTP request to the local OpenID4VP wallet's authorization endpoint that includes request parameters, // but with openid4vp: as scheme. // The context contains data from the previous request. Usage by the handler will probably result in incorrect behavior. - userWalletMetadata := authorizationServerMetadata(nil, r.vdr.SupportedMethods()) + userWalletMetadata := authorizationServerMetadata(nil, r.auth.SupportedDIDMethods()) response, err := r.handleAuthorizeRequest(ctx, subject, userWalletMetadata, *parsedRedirectURI) if err != nil { return nil, err diff --git a/auth/auth.go b/auth/auth.go index 4586b93c5b..0c934d0982 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -46,22 +46,23 @@ var _ AuthenticationServices = (*Auth)(nil) // Auth is the main struct of the Auth service type Auth struct { - config Config - jsonldManager jsonld.JSONLD - authzServer oauth.AuthorizationServer - relyingParty oauth.RelyingParty - contractNotary services.ContractNotary - serviceResolver didman.CompoundServiceResolver - keyStore crypto.KeyStore - vcr vcr.VCR - pkiProvider pki.Provider - shutdownFunc func() - vdrInstance vdr.VDR - publicURL *url.URL - strictMode bool - httpClientTimeout time.Duration - tlsConfig *tls.Config - subjectManager didsubject.Manager + config Config + jsonldManager jsonld.JSONLD + authzServer oauth.AuthorizationServer + relyingParty oauth.RelyingParty + contractNotary services.ContractNotary + serviceResolver didman.CompoundServiceResolver + keyStore crypto.KeyStore + vcr vcr.VCR + pkiProvider pki.Provider + shutdownFunc func() + vdrInstance vdr.VDR + publicURL *url.URL + strictMode bool + httpClientTimeout time.Duration + tlsConfig *tls.Config + subjectManager didsubject.Manager + supportedDIDMethods []string } // Name returns the name of the module. @@ -136,6 +137,8 @@ func (auth *Auth) Configure(config core.ServerConfig) error { return err } + auth.supportedDIDMethods = config.DIDMethods + auth.contractNotary = notary.NewNotary(notary.Config{ PublicURL: auth.publicURL.String(), IrmaConfigPath: path.Join(config.Datadir, "irma"), @@ -175,6 +178,10 @@ func (auth *Auth) Configure(config core.ServerConfig) error { return nil } +func (auth *Auth) SupportedDIDMethods() []string { + return auth.supportedDIDMethods +} + // Start starts the Auth engine (Noop) func (auth *Auth) Start() error { return nil diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 1dc1d9b056..0020fea550 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -22,6 +22,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" @@ -40,6 +41,12 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" ) +// ErrInvalidClientCall is returned when the node makes a http call as client based on wrong information passed by the client. +var ErrInvalidClientCall = errors.New("invalid client call") + +// ErrBadGateway is returned when the node makes a http call as client and the upstream returns an unexpected result. +var ErrBadGateway = errors.New("upstream returned unexpected result") + // HTTPClient holds the server address and other basic settings for the http client type HTTPClient struct { strictMode bool @@ -63,7 +70,14 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, oauth } var metadata oauth.AuthorizationServerMetadata if err = hb.doGet(ctx, metadataURL.String(), &metadata); err != nil { - return nil, err + // if this is a core.HttpError and the status code >= 500 then we want the caller to receive a 502 Bad Gateway + // we do this by changing the status code of the error + // any other error should result in a 400 Bad Request + if httpErr, ok := err.(core.HttpError); ok && httpErr.StatusCode >= 500 { + httpErr.StatusCode = http.StatusBadGateway + return nil, httpErr + } + return nil, errors.Join(ErrInvalidClientCall, err) } return &metadata, err } @@ -93,6 +107,16 @@ func (hb HTTPClient) PresentationDefinition(ctx context.Context, presentationDef return nil, err } var presentationDefinition pe.PresentationDefinition + err = hb.doRequest(ctx, request, &presentationDefinition) + if err != nil { + // any OAuth error should be passed + // any other error should result in a 502 Bad Gateway + if oauthErr, ok := err.(oauth.OAuth2Error); ok { + return nil, oauthErr + } + return nil, errors.Join(ErrBadGateway, err) + } + return &presentationDefinition, hb.doRequest(ctx, request, &presentationDefinition) } @@ -112,7 +136,7 @@ func (hb HTTPClient) RequestObjectByGet(ctx context.Context, requestURI string) return "", httpErr } - data, err := core.LimitedReadAll(response.Body) + data, err := io.ReadAll(response.Body) if err != nil { return "", fmt.Errorf("unable to read response: %w", err) } @@ -137,7 +161,7 @@ func (hb HTTPClient) RequestObjectByPost(ctx context.Context, requestURI string, return "", httpErr } - data, err := core.LimitedReadAll(response.Body) + data, err := io.ReadAll(response.Body) if err != nil { return "", fmt.Errorf("unable to read response: %w", err) } @@ -182,7 +206,7 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data } var responseData []byte - if responseData, err = core.LimitedReadAll(response.Body); err != nil { + if responseData, err = io.ReadAll(response.Body); err != nil { return token, fmt.Errorf("unable to read response: %w", err) } if err = json.Unmarshal(responseData, &token); err != nil { @@ -247,7 +271,7 @@ func (hb HTTPClient) OpenIDConfiguration(ctx context.Context, issuerURL string) return nil, httpErr } var data []byte - if data, err = core.LimitedReadAll(response.Body); err != nil { + if data, err = io.ReadAll(response.Body); err != nil { return nil, fmt.Errorf("unable to read response: %w", err) } // kid is checked against did resolver @@ -375,12 +399,15 @@ func (hb HTTPClient) doRequest(ctx context.Context, request *http.Request, targe if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidScope); ok { return oauthErr } + if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidRequest); ok { + return oauthErr + } return httpErr } var data []byte - if data, err = core.LimitedReadAll(response.Body); err != nil { + if data, err = io.ReadAll(response.Body); err != nil { return fmt.Errorf("unable to read response: %w", err) } if err = json.Unmarshal(data, &target); err != nil { diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go index 253a715083..a63a616741 100644 --- a/auth/client/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -78,6 +78,17 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { assert.Equal(t, "GET", handler.Request.Method) assert.Equal(t, "/.well-known/oauth-authorization-server/iam/123", handler.Request.URL.Path) }) + t.Run("error - server error changes status code to 502", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusInternalServerError} + tlsServer, client := testServerAndClient(t, &handler) + + _, err := client.OAuthAuthorizationServerMetadata(ctx, tlsServer.URL) + + require.Error(t, err) + httpErr, ok := err.(core.HttpError) + require.True(t, ok) + assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode) + }) } func TestHTTPClient_PresentationDefinition(t *testing.T) { @@ -98,6 +109,28 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { assert.Equal(t, definition, *response) require.NotNil(t, handler.Request) }) + t.Run("error - generic error results in 502", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusInternalServerError} + tlsServer, client := testServerAndClient(t, &handler) + pdUrl := test.MustParseURL(tlsServer.URL) + + _, err := client.PresentationDefinition(ctx, *pdUrl) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrBadGateway) + }) + t.Run("error - oauth error", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: oauth.OAuth2Error{Code: oauth.InvalidRequest}} + tlsServer, client := testServerAndClient(t, &handler) + pdUrl := test.MustParseURL(tlsServer.URL) + + _, err := client.PresentationDefinition(ctx, *pdUrl) + + require.Error(t, err) + oauthErr, ok := err.(oauth.OAuth2Error) + require.True(t, ok) + assert.Equal(t, oauth.InvalidRequest, oauthErr.Code) + }) } func TestHTTPClient_AccessToken(t *testing.T) { diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 338a080640..add1a059cd 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -23,6 +23,7 @@ import ( type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder + isgomock struct{} } // MockClientMockRecorder is the mock recorder for MockClient. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 437cd95432..0f9e370601 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -22,6 +22,7 @@ package iam import ( "context" "encoding/json" + "errors" "fmt" "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -46,6 +47,9 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" ) +// ErrPreconditionFailed is returned when a precondition is not met. +var ErrPreconditionFailed = errors.New("precondition failed") + var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { @@ -282,7 +286,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID for key := range maps.Keys(allMethods) { availableMethods = append(availableMethods, key) } - return nil, fmt.Errorf("did method mismatch, requested: %v, available: %v", metadata.DIDMethodsSupported, availableMethods) + return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", metadata.DIDMethodsSupported, availableMethods)) } // each additional credential can be used by each DID @@ -320,7 +324,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID } dpopHeader, dpopKid, err = c.dpop(ctx, *subjectDID, *request) if err != nil { - return nil, fmt.Errorf("failed tocreate DPoP header: %w", err) + return nil, fmt.Errorf("failed to create DPoP header: %w", err) } } @@ -360,16 +364,6 @@ func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialE return rsp, nil } -func (c *OpenID4VPClient) walletWithExtraCredentials(ctx context.Context, subject did.DID, credentials []vc.VerifiableCredential) (holder.Wallet, error) { - walletCredentials, err := c.wallet.List(ctx, subject) - if err != nil { - return nil, err - } - return holder.NewMemoryWallet(c.ldDocumentLoader, c.keyResolver, c.jwtSigner, map[did.DID][]vc.VerifiableCredential{ - subject: append(walletCredentials, credentials...), - }), nil -} - func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) { // find the key to sign the DPoP token with keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 7f106c53b9..8c383ddb9e 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -22,6 +22,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "github.com/nuts-foundation/nuts-node/http/client" test2 "github.com/nuts-foundation/nuts-node/test" @@ -230,7 +231,8 @@ func TestIAMClient_AuthorizationServerMetadata(t *testing.T) { _, err := ctx.client.AuthorizationServerMetadata(context.Background(), ctx.tlsServer.URL) require.Error(t, err) - assert.EqualError(t, err, "failed to retrieve remote OAuth Authorization Server metadata: server returned HTTP 404 (expected: 200)") + assert.ErrorIs(t, err, ErrInvalidClientCall) + assert.ErrorContains(t, err, "server returned HTTP 404 (expected: 200)") }) } @@ -275,7 +277,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) - assert.EqualError(t, err, "did method mismatch, requested: [other], available: [test]") + require.Error(t, err) + assert.ErrorIs(t, err, ErrPreconditionFailed) + assert.ErrorContains(t, err, "did method mismatch, requested: [other], available: [test]") assert.Nil(t, response) }) t.Run("with additional credentials", func(t *testing.T) { @@ -353,12 +357,19 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to get presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) - ctx.presentationDefinition = nil + ctx.presentationDefinition = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + bytes, _ := json.Marshal(oauth.OAuth2Error{Code: oauth.InvalidScope}) + _, _ = writer.Write(bytes) + return + } _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) - assert.Error(t, err) - assert.EqualError(t, err, "failed to retrieve presentation definition: server returned HTTP 404 (expected: 200)") + require.Error(t, err) + assert.True(t, errors.As(err, &oauth.OAuth2Error{})) + assert.ErrorContains(t, err, string(oauth.InvalidScope)) }) t.Run("error - failed to get authorization server metadata", func(t *testing.T) { ctx := createClientServerTestContext(t) @@ -366,8 +377,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) - assert.Error(t, err) - assert.EqualError(t, err, "failed to retrieve remote OAuth Authorization Server metadata: server returned HTTP 404 (expected: 200)") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidClientCall) + assert.ErrorContains(t, err, "server returned HTTP 404 (expected: 200)") }) t.Run("error - faulty presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) @@ -379,8 +391,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) - assert.Error(t, err) - assert.EqualError(t, err, "failed to retrieve presentation definition: unable to unmarshal response: unexpected end of JSON input") + require.Error(t, err) + assert.ErrorIs(t, err, ErrBadGateway) + assert.ErrorContains(t, err, "unable to unmarshal response: unexpected end of JSON input") }) t.Run("error - failed to build vp", func(t *testing.T) { ctx := createClientServerTestContext(t) diff --git a/auth/contract/signer_mock.go b/auth/contract/signer_mock.go index 75956eb70a..982dbda0a7 100644 --- a/auth/contract/signer_mock.go +++ b/auth/contract/signer_mock.go @@ -21,6 +21,7 @@ import ( type MockSigner struct { ctrl *gomock.Controller recorder *MockSignerMockRecorder + isgomock struct{} } // MockSignerMockRecorder is the mock recorder for MockSigner. @@ -86,6 +87,7 @@ func (mr *MockSignerMockRecorder) StartSigningSession(contract, params any) *gom type MockSessionPointer struct { ctrl *gomock.Controller recorder *MockSessionPointerMockRecorder + isgomock struct{} } // MockSessionPointerMockRecorder is the mock recorder for MockSessionPointer. @@ -152,6 +154,7 @@ func (mr *MockSessionPointerMockRecorder) SessionID() *gomock.Call { type MockSigningSessionResult struct { ctrl *gomock.Controller recorder *MockSigningSessionResultMockRecorder + isgomock struct{} } // MockSigningSessionResultMockRecorder is the mock recorder for MockSigningSessionResult. diff --git a/auth/interface.go b/auth/interface.go index ee1cad0c65..bae296c0da 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -42,4 +42,6 @@ type AuthenticationServices interface { PublicURL() *url.URL // AuthorizationEndpointEnabled returns whether the v2 API's OAuth2 Authorization Endpoint is enabled. AuthorizationEndpointEnabled() bool + // SupportedDIDMethods list the DID methods configured for the nuts node in preferred order. + SupportedDIDMethods() []string } diff --git a/auth/mock.go b/auth/mock.go index a9beb62de3..e92db3f34a 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -23,6 +23,7 @@ import ( type MockAuthenticationServices struct { ctrl *gomock.Controller recorder *MockAuthenticationServicesMockRecorder + isgomock struct{} } // MockAuthenticationServicesMockRecorder is the mock recorder for MockAuthenticationServices. @@ -125,3 +126,17 @@ func (mr *MockAuthenticationServicesMockRecorder) RelyingParty() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RelyingParty", reflect.TypeOf((*MockAuthenticationServices)(nil).RelyingParty)) } + +// SupportedDIDMethods mocks base method. +func (m *MockAuthenticationServices) SupportedDIDMethods() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SupportedDIDMethods") + ret0, _ := ret[0].([]string) + return ret0 +} + +// SupportedDIDMethods indicates an expected call of SupportedDIDMethods. +func (mr *MockAuthenticationServicesMockRecorder) SupportedDIDMethods() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportedDIDMethods", reflect.TypeOf((*MockAuthenticationServices)(nil).SupportedDIDMethods)) +} diff --git a/auth/services/mock.go b/auth/services/mock.go index 10c44d006e..94133bf6a7 100644 --- a/auth/services/mock.go +++ b/auth/services/mock.go @@ -25,6 +25,7 @@ import ( type MockSignedToken struct { ctrl *gomock.Controller recorder *MockSignedTokenMockRecorder + isgomock struct{} } // MockSignedTokenMockRecorder is the mock recorder for MockSignedToken. @@ -77,6 +78,7 @@ func (mr *MockSignedTokenMockRecorder) SignerAttributes() *gomock.Call { type MockVPProofValueParser struct { ctrl *gomock.Controller recorder *MockVPProofValueParserMockRecorder + isgomock struct{} } // MockVPProofValueParserMockRecorder is the mock recorder for MockVPProofValueParser. @@ -129,6 +131,7 @@ func (mr *MockVPProofValueParserMockRecorder) Verify(token any) *gomock.Call { type MockContractNotary struct { ctrl *gomock.Controller recorder *MockContractNotaryMockRecorder + isgomock struct{} } // MockContractNotaryMockRecorder is the mock recorder for MockContractNotary. diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go index cceaafa547..f49e2d9a1e 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -23,6 +23,7 @@ import ( type MockRelyingParty struct { ctrl *gomock.Controller recorder *MockRelyingPartyMockRecorder + isgomock struct{} } // MockRelyingPartyMockRecorder is the mock recorder for MockRelyingParty. @@ -76,6 +77,7 @@ func (mr *MockRelyingPartyMockRecorder) RequestRFC003AccessToken(ctx, jwtGrantTo type MockAuthorizationServer struct { ctrl *gomock.Controller recorder *MockAuthorizationServerMockRecorder + isgomock struct{} } // MockAuthorizationServerMockRecorder is the mock recorder for MockAuthorizationServer. diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 1b9b7819f2..754f55bf11 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -22,12 +22,11 @@ import ( "context" "crypto/tls" "fmt" - "github.com/lestrrat-go/jwx/v2/jwt" - "net/http" "net/url" "strings" "time" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -35,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" + strictHttp "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -110,12 +110,7 @@ func (s *relyingParty) RequestRFC003AccessToken(ctx context.Context, jwtGrantTok if s.strictMode && strings.ToLower(authorizationServerEndpoint.Scheme) != "https" { return nil, fmt.Errorf("authorization server endpoint must be HTTPS when in strict mode: %s", authorizationServerEndpoint.String()) } - httpClient := &http.Client{} - if s.httpClientTLS != nil { - httpClient.Transport = &http.Transport{ - TLSClientConfig: s.httpClientTLS, - } - } + httpClient := strictHttp.NewWithTLSConfig(s.httpClientTimeout, s.httpClientTLS) authClient, err := client.NewHTTPClient("", s.httpClientTimeout, client.WithHTTPClient(httpClient), client.WithRequestEditorFn(core.UserAgentRequestEditor)) if err != nil { return nil, fmt.Errorf("unable to create HTTP client: %w", err) diff --git a/auth/services/selfsigned/types/mock.go b/auth/services/selfsigned/types/mock.go index 4707ec8b73..c24e81cff4 100644 --- a/auth/services/selfsigned/types/mock.go +++ b/auth/services/selfsigned/types/mock.go @@ -20,6 +20,7 @@ import ( type MockSessionStore struct { ctrl *gomock.Controller recorder *MockSessionStoreMockRecorder + isgomock struct{} } // MockSessionStoreMockRecorder is the mock recorder for MockSessionStore. diff --git a/auth/services/selfsigned/web/generated.go b/auth/services/selfsigned/web/generated.go index 51648b1827..143503944c 100644 --- a/auth/services/selfsigned/web/generated.go +++ b/auth/services/selfsigned/web/generated.go @@ -1,6 +1,6 @@ // Package web provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package web import ( diff --git a/cmd/root.go b/cmd/root.go index 6694000835..a14feab268 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -198,7 +198,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { vdrInstance := vdr.NewVDR(cryptoInstance, networkInstance, didStore, eventManager, storageInstance, pkiInstance) credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) - discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance) + discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance, vdrInstance) authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance) statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() @@ -206,20 +206,18 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { policyInstance := policy.New() // Register HTTP routes + didKeyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()} system.RegisterRoutes(&core.LandingPage{}) - system.RegisterRoutes(&cryptoAPI.Wrapper{C: cryptoInstance, K: resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()}}) + system.RegisterRoutes(&cryptoAPI.Wrapper{C: cryptoInstance, K: didKeyResolver}) system.RegisterRoutes(&networkAPI.Wrapper{Service: networkInstance}) system.RegisterRoutes(&vdrAPI.Wrapper{VDR: vdrInstance, SubjectManager: vdrInstance}) system.RegisterRoutes(&vdrAPIv2.Wrapper{VDR: vdrInstance, SubjectManager: vdrInstance}) system.RegisterRoutes(&vcrAPI.Wrapper{VCR: credentialInstance, ContextManager: jsonld, SubjectManager: vdrInstance}) - system.RegisterRoutes(&openid4vciAPI.Wrapper{ - VCR: credentialInstance, - VDR: vdrInstance, - }) + system.RegisterRoutes(&openid4vciAPI.Wrapper{VCR: credentialInstance, VDR: vdrInstance}) system.RegisterRoutes(statusEngine.(core.Routable)) system.RegisterRoutes(metricsEngine.(core.Routable)) system.RegisterRoutes(&authAPIv1.Wrapper{Auth: authInstance, CredentialResolver: credentialInstance}) - system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, vdrInstance, storageInstance, policyInstance, cryptoInstance, jsonld)) + system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, didKeyResolver, vdrInstance, storageInstance, policyInstance, cryptoInstance, jsonld)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) system.RegisterRoutes(&discoveryAPI.Wrapper{Client: discoveryInstance}) @@ -334,7 +332,6 @@ func serverConfigFlags() *pflag.FlagSet { set.AddFlagSet(httpCmd.FlagSet()) set.AddFlagSet(storageCmd.FlagSet()) set.AddFlagSet(networkCmd.FlagSet()) - set.AddFlagSet(vdrCmd.FlagSet()) set.AddFlagSet(vcrCmd.FlagSet()) set.AddFlagSet(jsonld.FlagSet()) set.AddFlagSet(authCmd.FlagSet()) diff --git a/core/client_config.go b/core/client_config.go index 02bf964440..5e44d433a6 100644 --- a/core/client_config.go +++ b/core/client_config.go @@ -21,13 +21,13 @@ package core import ( "fmt" + "github.com/knadh/koanf/v2" "github.com/spf13/cobra" "os" "path" "strings" "time" - "github.com/knadh/koanf" "github.com/spf13/pflag" ) diff --git a/core/client_config_test.go b/core/client_config_test.go index 03c7713814..184c150142 100644 --- a/core/client_config_test.go +++ b/core/client_config_test.go @@ -20,7 +20,7 @@ package core import ( - "github.com/knadh/koanf" + "github.com/knadh/koanf/v2" "github.com/nuts-foundation/nuts-node/test/io" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" diff --git a/core/config.go b/core/config.go index 0d9092085c..b484b9719f 100644 --- a/core/config.go +++ b/core/config.go @@ -22,11 +22,11 @@ package core import ( "errors" "fmt" - "github.com/knadh/koanf" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/v2" "github.com/spf13/pflag" "os" "strings" diff --git a/core/config_test.go b/core/config_test.go index e106dc04ef..e5009382ea 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -20,7 +20,7 @@ package core import ( - "github.com/knadh/koanf" + "github.com/knadh/koanf/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/stretchr/testify/assert" diff --git a/core/echo_mock.go b/core/echo_mock.go index 43d7fc34f5..236b0899ff 100644 --- a/core/echo_mock.go +++ b/core/echo_mock.go @@ -20,6 +20,7 @@ import ( type MockEchoRouter struct { ctrl *gomock.Controller recorder *MockEchoRouterMockRecorder + isgomock struct{} } // MockEchoRouterMockRecorder is the mock recorder for MockEchoRouter. diff --git a/core/engine_mock.go b/core/engine_mock.go index 7bfb0a4513..34a47eef43 100644 --- a/core/engine_mock.go +++ b/core/engine_mock.go @@ -19,6 +19,7 @@ import ( type MockRoutable struct { ctrl *gomock.Controller recorder *MockRoutableMockRecorder + isgomock struct{} } // MockRoutableMockRecorder is the mock recorder for MockRoutable. @@ -54,6 +55,7 @@ func (mr *MockRoutableMockRecorder) Routes(router any) *gomock.Call { type MockRunnable struct { ctrl *gomock.Controller recorder *MockRunnableMockRecorder + isgomock struct{} } // MockRunnableMockRecorder is the mock recorder for MockRunnable. @@ -105,6 +107,7 @@ func (mr *MockRunnableMockRecorder) Start() *gomock.Call { type MockMigratable struct { ctrl *gomock.Controller recorder *MockMigratableMockRecorder + isgomock struct{} } // MockMigratableMockRecorder is the mock recorder for MockMigratable. @@ -142,6 +145,7 @@ func (mr *MockMigratableMockRecorder) Migrate() *gomock.Call { type MockConfigurable struct { ctrl *gomock.Controller recorder *MockConfigurableMockRecorder + isgomock struct{} } // MockConfigurableMockRecorder is the mock recorder for MockConfigurable. @@ -179,6 +183,7 @@ func (mr *MockConfigurableMockRecorder) Configure(config any) *gomock.Call { type MockViewableDiagnostics struct { ctrl *gomock.Controller recorder *MockViewableDiagnosticsMockRecorder + isgomock struct{} } // MockViewableDiagnosticsMockRecorder is the mock recorder for MockViewableDiagnostics. @@ -230,6 +235,7 @@ func (mr *MockViewableDiagnosticsMockRecorder) Name() *gomock.Call { type MockDiagnosable struct { ctrl *gomock.Controller recorder *MockDiagnosableMockRecorder + isgomock struct{} } // MockDiagnosableMockRecorder is the mock recorder for MockDiagnosable. @@ -267,6 +273,7 @@ func (mr *MockDiagnosableMockRecorder) Diagnostics() *gomock.Call { type MockEngine struct { ctrl *gomock.Controller recorder *MockEngineMockRecorder + isgomock struct{} } // MockEngineMockRecorder is the mock recorder for MockEngine. @@ -290,6 +297,7 @@ func (m *MockEngine) EXPECT() *MockEngineMockRecorder { type MockNamed struct { ctrl *gomock.Controller recorder *MockNamedMockRecorder + isgomock struct{} } // MockNamedMockRecorder is the mock recorder for MockNamed. @@ -327,6 +335,7 @@ func (mr *MockNamedMockRecorder) Name() *gomock.Call { type MockInjectable struct { ctrl *gomock.Controller recorder *MockInjectableMockRecorder + isgomock struct{} } // MockInjectableMockRecorder is the mock recorder for MockInjectable. @@ -378,6 +387,7 @@ func (mr *MockInjectableMockRecorder) Name() *gomock.Call { type MockHealthCheckable struct { ctrl *gomock.Controller recorder *MockHealthCheckableMockRecorder + isgomock struct{} } // MockHealthCheckableMockRecorder is the mock recorder for MockHealthCheckable. diff --git a/core/http_client.go b/core/http_client.go index dc9aff03bb..53dbc01918 100644 --- a/core/http_client.go +++ b/core/http_client.go @@ -31,21 +31,6 @@ import ( // If the response body is longer than this, it will be truncated. const HttpResponseBodyLogClipAt = 200 -// DefaultMaxHttpResponseSize is a default maximum size of an HTTP response body that will be read. -// Very large or unbounded HTTP responses can cause denial-of-service, so it's good to limit how much data is read. -// This of course heavily depends on the use case, but 1MB is a reasonable default. -const DefaultMaxHttpResponseSize = 1024 * 1024 - -// LimitedReadAll reads the given reader until the DefaultMaxHttpResponseSize is reached. -// It returns an error if more data is available than DefaultMaxHttpResponseSize. -func LimitedReadAll(reader io.Reader) ([]byte, error) { - result, err := io.ReadAll(io.LimitReader(reader, DefaultMaxHttpResponseSize+1)) - if len(result) > DefaultMaxHttpResponseSize { - return nil, fmt.Errorf("data to read exceeds max. safety limit of %d bytes", DefaultMaxHttpResponseSize) - } - return result, err -} - // HttpError describes an error returned when invoking a remote server. type HttpError struct { error @@ -63,7 +48,7 @@ func TestResponseCode(expectedStatusCode int, response *http.Response) error { // It logs using the given logger, unless nil is passed. func TestResponseCodeWithLog(expectedStatusCode int, response *http.Response, log *logrus.Entry) error { if response.StatusCode != expectedStatusCode { - responseData, _ := LimitedReadAll(response.Body) + responseData, _ := io.ReadAll(response.Body) if log != nil { // Cut off the response body to 100 characters max to prevent logging of large responses responseBodyString := string(responseData) @@ -104,16 +89,18 @@ func (w httpRequestDoerAdapter) Do(req *http.Request) (*http.Response, error) { return w.fn(req) } -// CreateHTTPClient creates a new HTTP client with the given client configuration. +// CreateHTTPInternalClient creates a new HTTP client with the given client configuration. +// This client is to be used for internal API calls (CMDs and such) // The result HTTPRequestDoer can be supplied to OpenAPI generated clients for executing requests. // This does not use the generated client options for e.g. authentication, // because each generated OpenAPI client reimplements the client options using structs, // which makes them incompatible with each other, making it impossible to use write generic client code for common traits like authorization. // If the given authorization token builder is non-nil, it calls it and passes the resulting token as bearer token with requests. -func CreateHTTPClient(cfg ClientConfig, generator AuthorizationTokenGenerator) (HTTPRequestDoer, error) { +func CreateHTTPInternalClient(cfg ClientConfig, generator AuthorizationTokenGenerator) (HTTPRequestDoer, error) { var result *httpRequestDoerAdapter client := &http.Client{} client.Timeout = cfg.Timeout + result = &httpRequestDoerAdapter{ fn: client.Do, } @@ -149,9 +136,9 @@ func CreateHTTPClient(cfg ClientConfig, generator AuthorizationTokenGenerator) ( return result, nil } -// MustCreateHTTPClient is like CreateHTTPClient but panics if it returns an error. -func MustCreateHTTPClient(cfg ClientConfig, generator AuthorizationTokenGenerator) HTTPRequestDoer { - client, err := CreateHTTPClient(cfg, generator) +// MustCreateInternalHTTPClient is like CreateHTTPInternalClient but panics if it returns an error. +func MustCreateInternalHTTPClient(cfg ClientConfig, generator AuthorizationTokenGenerator) HTTPRequestDoer { + client, err := CreateHTTPInternalClient(cfg, generator) if err != nil { panic(err) } diff --git a/core/http_client_test.go b/core/http_client_test.go index 891364b279..1bc9e31835 100644 --- a/core/http_client_test.go +++ b/core/http_client_test.go @@ -44,7 +44,7 @@ func TestHTTPClient(t *testing.T) { t.Run("no auth token", func(t *testing.T) { authToken = "" - client, err := CreateHTTPClient(ClientConfig{}, nil) + client, err := CreateHTTPInternalClient(ClientConfig{}, nil) require.NoError(t, err) req, _ := stdHttp.NewRequest(stdHttp.MethodGet, server.URL, nil) @@ -56,7 +56,7 @@ func TestHTTPClient(t *testing.T) { }) t.Run("with auth token", func(t *testing.T) { authToken = "" - client, err := CreateHTTPClient(ClientConfig{ + client, err := CreateHTTPInternalClient(ClientConfig{ Token: "test", }, nil) require.NoError(t, err) @@ -69,7 +69,7 @@ func TestHTTPClient(t *testing.T) { assert.Equal(t, "Bearer test", authToken) }) t.Run("with custom token builder", func(t *testing.T) { - client, err := CreateHTTPClient(ClientConfig{}, newLegacyTokenGenerator("test")) + client, err := CreateHTTPInternalClient(ClientConfig{}, newLegacyTokenGenerator("test")) require.NoError(t, err) req, _ := stdHttp.NewRequest(stdHttp.MethodGet, server.URL, nil) @@ -80,7 +80,7 @@ func TestHTTPClient(t *testing.T) { assert.Equal(t, "Bearer test", authToken) }) t.Run("with errored token builder", func(t *testing.T) { - client, err := CreateHTTPClient(ClientConfig{}, newErrorTokenBuilder()) + client, err := CreateHTTPInternalClient(ClientConfig{}, newErrorTokenBuilder()) require.NoError(t, err) req, _ := stdHttp.NewRequest(stdHttp.MethodGet, server.URL, nil) @@ -162,20 +162,3 @@ func newErrorTokenBuilder() AuthorizationTokenGenerator { return "", errors.New("error") } } - -func TestLimitedReadAll(t *testing.T) { - t.Run("less than limit", func(t *testing.T) { - data := strings.Repeat("a", 10) - result, err := LimitedReadAll(strings.NewReader(data)) - - assert.NoError(t, err) - assert.Equal(t, []byte(data), result) - }) - t.Run("more than limit", func(t *testing.T) { - data := strings.Repeat("a", DefaultMaxHttpResponseSize+1) - result, err := LimitedReadAll(strings.NewReader(data)) - - assert.EqualError(t, err, "data to read exceeds max. safety limit of 1048576 bytes") - assert.Nil(t, result) - }) -} diff --git a/core/server_config.go b/core/server_config.go index d2a9b0de6e..ac7143dd5a 100644 --- a/core/server_config.go +++ b/core/server_config.go @@ -25,9 +25,9 @@ import ( "crypto/x509" "errors" "fmt" - "github.com/knadh/koanf" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/v2" "github.com/sirupsen/logrus" "github.com/spf13/pflag" "net/url" @@ -56,6 +56,7 @@ var redactedConfigKeys = []string{ type ServerConfig struct { // URL contains the base URL for public-facing HTTP services. URL string `koanf:"url"` + DIDMethods []string `koanf:"didmethods"` Verbosity string `koanf:"verbosity"` LoggerFormat string `koanf:"loggerformat"` CPUProfile string `koanf:"cpuprofile"` @@ -67,9 +68,17 @@ type ServerConfig struct { // LegacyTLS exists to detect usage of deprecated network.{truststorefile,certkeyfile,certfile} parameters. // This can be removed in v6.1+ (can't skip minors in migration). See https://github.com/nuts-foundation/nuts-node/issues/2909 LegacyTLS TLSConfig `koanf:"network"` + // HTTP exists to expose http.clientipheader to the nuts-network layer. + // This header should contaisn the client IP address for logging. Can be removed together with the nuts-network + HTTP HTTPConfig `koanf:"http"` configMap *koanf.Koanf } +// Config is the top-level config struct for HTTP interfaces. +type HTTPConfig struct { + ClientIPHeaderName string `koanf:"clientipheader"` +} + // HTTPClientConfig contains settings for HTTP clients. type HTTPClientConfig struct { // Timeout specifies the timeout for HTTP requests. @@ -156,6 +165,7 @@ func NewServerConfig() *ServerConfig { Strictmode: true, InternalRateLimiter: true, Datadir: "./data", + DIDMethods: []string{"web", "nuts"}, TLS: TLSConfig{ TrustStoreFile: "./config/ssl/truststore.pem", Offload: NoOffloading, @@ -253,6 +263,8 @@ func FlagSet() *pflag.FlagSet { flagSet.Bool("internalratelimiter", defaultCfg.InternalRateLimiter, "When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode.") flagSet.String("datadir", defaultCfg.Datadir, "Directory where the node stores its files.") flagSet.String("url", defaultCfg.URL, "Public facing URL of the server (required). Must be HTTPS when strictmode is set.") + flagSet.StringSlice("didmethods", defaultCfg.DIDMethods, "Comma-separated list of enabled DID methods (without did: prefix). "+ + "It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used.") flagSet.Duration("httpclient.timeout", defaultCfg.HTTPClient.Timeout, "Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax.") flagSet.String("tls.certfile", defaultCfg.TLS.CertFile, "PEM file containing the certificate for the gRPC server (also used as client certificate). Required in strict mode.") flagSet.String("tls.certkeyfile", defaultCfg.TLS.CertKeyFile, "PEM file containing the private key of the gRPC server certificate. Required in strict mode.") diff --git a/crypto/api/v1/generated.go b/crypto/api/v1/generated.go index e4fe5726d1..e14065de5d 100644 --- a/crypto/api/v1/generated.go +++ b/crypto/api/v1/generated.go @@ -1,6 +1,6 @@ // Package v1 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v1 import ( diff --git a/crypto/cmd/cmd_test.go b/crypto/cmd/cmd_test.go index 4cd82aba09..34f9a2bc9a 100644 --- a/crypto/cmd/cmd_test.go +++ b/crypto/cmd/cmd_test.go @@ -92,11 +92,10 @@ func Test_fs2VaultCommand(t *testing.T) { // Configure target t.Setenv("NUTS_CRYPTO_STORAGE", "vaultkv") t.Setenv("NUTS_CRYPTO_VAULT_ADDRESS", s.URL) + t.Setenv("NUTS_STRICTMODE", "false") testDirectory := testIo.TestDirectory(t) setupFSStoreData(t, testDirectory) - // default datadir is unavailable causing sqlite to fail - t.Setenv("NUTS_DATADIR", testDirectory) outBuf := new(bytes.Buffer) cryptoCmd := ServerCmd() diff --git a/crypto/crypto.go b/crypto/crypto.go index b2a4c8f7b3..a592c46066 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -199,7 +199,7 @@ func (client *Crypto) Migrate() error { for _, keyNameVersion := range keys { var keyRef orm.KeyReference // find existing record, if it exists do nothing - err := tx.WithContext(ctx).Model(&orm.KeyReference{}).Where("key_name = ? and version = ?", keyNameVersion.KeyName, keyNameVersion.KeyName).First(&keyRef).Error + err := tx.WithContext(ctx).Model(&orm.KeyReference{}).Where("key_name = ? and version = ?", keyNameVersion.KeyName, keyNameVersion.Version).First(&keyRef).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // create a new key reference diff --git a/crypto/crypto_test.go b/crypto/crypto_test.go index a5eed99de1..76d8ed795d 100644 --- a/crypto/crypto_test.go +++ b/crypto/crypto_test.go @@ -61,12 +61,11 @@ func TestCrypto_Exists(t *testing.T) { } func TestCrypto_Migrate(t *testing.T) { - backend := NewMemoryStorage() - db := orm.NewTestDatabase(t) - client := &Crypto{backend: backend, db: db} - + keypair, _ := spi.GenerateKeyPair() t.Run("ok - 1 key migrated", func(t *testing.T) { - keypair, _ := spi.GenerateKeyPair() + backend := NewMemoryStorage() + db := orm.NewTestDatabase(t) + client := &Crypto{backend: backend, db: db} err := backend.SavePrivateKey(context.Background(), "test", keypair) require.NoError(t, err) @@ -80,9 +79,26 @@ func TestCrypto_Migrate(t *testing.T) { t.Run("ok - already exists", func(t *testing.T) { err = client.Migrate() + assert.NoError(t, err) }) }) + t.Run("don't migrate new keys", func(t *testing.T) { + backend := NewMemoryStorage() + db := orm.NewTestDatabase(t) + client := &Crypto{backend: backend, db: db} + err := backend.SavePrivateKey(context.Background(), "some-uuid", keypair) + require.NoError(t, err) + + err = db.Save(&orm.KeyReference{KID: "vm-id", KeyName: "some-uuid", Version: "1"}).Error + require.NoError(t, err) + + err = client.Migrate() + require.NoError(t, err) + + keys := client.List(context.Background()) + require.Len(t, keys, 1) + }) } func TestCrypto_New(t *testing.T) { diff --git a/crypto/jwx.go b/crypto/jwx.go index a0779d15e8..381f42a2a4 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -82,9 +82,7 @@ func (client *Crypto) SignJWS(ctx context.Context, payload []byte, headers map[s return "", err } - if _, ok := headers["jwk"]; !ok { - headers["kid"] = kid - } + headers["kid"] = kid return SignJWS(ctx, payload, headers, privateKey, detached) } @@ -247,10 +245,13 @@ func SignJWS(ctx context.Context, payload []byte, protectedHeaders map[string]in return "", fmt.Errorf("unable to set header %s: %w", key, err) } } - // The JWX library is fine with creating a JWK for a private key (including the private exponents), so - // we want to make sure the `jwk` header (if present) does not (accidentally) contain a private key. - // That would lead to the node leaking its private key material in the resulting JWS which would be very, very bad. if headers.JWK() != nil { + // 'kid' has been logged, use 'jwk' to sign + _ = headers.Remove(jwk.KeyIDKey) + + // The JWX library is fine with creating a JWK for a private key (including the private exponents), so + // we want to make sure the `jwk` header (if present) does not (accidentally) contain a private key. + // That would lead to the node leaking its private key material in the resulting JWS which would be very, very bad. var jwkAsPrivateKey crypto.Signer if err := headers.JWK().Raw(&jwkAsPrivateKey); err == nil { // `err != nil` is good in this case, because that means the key is not assignable to crypto.Signer, diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go index a87a343f7e..4aea26947f 100644 --- a/crypto/jwx_test.go +++ b/crypto/jwx_test.go @@ -281,6 +281,24 @@ func TestCrypto_SignJWS(t *testing.T) { require.NoError(t, err) auditLogs.AssertContains(t, ModuleName, "SignJWS", audit.TestActor, "Signing a JWS with key: kid") }) + t.Run("writes audit log for jwk", func(t *testing.T) { + auditLogs := audit.CaptureAuditLogs(t) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + publicKeyAsJWK, _ := jwk.FromRaw(key.Public()) + hdrs := map[string]interface{}{ + "kid": kid, + "jwk": publicKeyAsJWK, + } + + signature, err := SignJWS(audit.TestContext(), []byte{1, 2, 3}, hdrs, key, false) + + require.NoError(t, err) + auditLogs.AssertContains(t, ModuleName, "SignJWS", audit.TestActor, "Signing a JWS with key: kid") + // kid is not in headers + msg, err := jws.Parse([]byte(signature)) + assert.Empty(t, msg.Signatures()[0].ProtectedHeaders().KeyID()) + }) t.Run("returns error for not found", func(t *testing.T) { payload, _ := json.Marshal(map[string]interface{}{"iss": "nuts"}) diff --git a/crypto/memory.go b/crypto/memory.go index 31b9bdad55..56d93ab951 100644 --- a/crypto/memory.go +++ b/crypto/memory.go @@ -66,9 +66,7 @@ func (m MemoryJWTSigner) SignJWS(ctx context.Context, payload []byte, headers ma if kid != m.Key.KeyID() { return "", ErrPrivateKeyNotFound } - if _, ok := headers["jwk"]; !ok { - headers["kid"] = kid - } + headers["kid"] = kid return SignJWS(ctx, payload, headers, signer, detached) } diff --git a/crypto/mock.go b/crypto/mock.go index c14177c5e6..e2192cba07 100644 --- a/crypto/mock.go +++ b/crypto/mock.go @@ -23,6 +23,7 @@ import ( type MockKeyCreator struct { ctrl *gomock.Controller recorder *MockKeyCreatorMockRecorder + isgomock struct{} } // MockKeyCreatorMockRecorder is the mock recorder for MockKeyCreator. @@ -62,6 +63,7 @@ func (mr *MockKeyCreatorMockRecorder) New(ctx, namingFunc any) *gomock.Call { type MockKeyResolver struct { ctrl *gomock.Controller recorder *MockKeyResolverMockRecorder + isgomock struct{} } // MockKeyResolverMockRecorder is the mock recorder for MockKeyResolver. @@ -129,6 +131,7 @@ func (mr *MockKeyResolverMockRecorder) Resolve(ctx, kid any) *gomock.Call { type MockKeyStore struct { ctrl *gomock.Controller recorder *MockKeyStoreMockRecorder + isgomock struct{} } // MockKeyStoreMockRecorder is the mock recorder for MockKeyStore. @@ -331,6 +334,7 @@ func (mr *MockKeyStoreMockRecorder) SignJWT(ctx, claims, headers, kid any) *gomo type MockDecrypter struct { ctrl *gomock.Controller recorder *MockDecrypterMockRecorder + isgomock struct{} } // MockDecrypterMockRecorder is the mock recorder for MockDecrypter. @@ -369,6 +373,7 @@ func (mr *MockDecrypterMockRecorder) Decrypt(ctx, kid, ciphertext any) *gomock.C type MockJWTSigner struct { ctrl *gomock.Controller recorder *MockJWTSignerMockRecorder + isgomock struct{} } // MockJWTSignerMockRecorder is the mock recorder for MockJWTSigner. @@ -437,6 +442,7 @@ func (mr *MockJWTSignerMockRecorder) SignJWT(ctx, claims, headers, kid any) *gom type MockJsonWebEncryptor struct { ctrl *gomock.Controller recorder *MockJsonWebEncryptorMockRecorder + isgomock struct{} } // MockJsonWebEncryptorMockRecorder is the mock recorder for MockJsonWebEncryptor. diff --git a/crypto/storage/azure/mock.go b/crypto/storage/azure/mock.go index 7ae8262a21..dbab3ddecd 100644 --- a/crypto/storage/azure/mock.go +++ b/crypto/storage/azure/mock.go @@ -22,6 +22,7 @@ import ( type MockkeyVaultClient struct { ctrl *gomock.Controller recorder *MockkeyVaultClientMockRecorder + isgomock struct{} } // MockkeyVaultClientMockRecorder is the mock recorder for MockkeyVaultClient. diff --git a/crypto/storage/external/generated.go b/crypto/storage/external/generated.go index 500120642a..30a6c66008 100644 --- a/crypto/storage/external/generated.go +++ b/crypto/storage/external/generated.go @@ -1,6 +1,6 @@ // Package external provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package external import ( diff --git a/crypto/storage/spi/mock.go b/crypto/storage/spi/mock.go index ccb7e78490..13fdec0739 100644 --- a/crypto/storage/spi/mock.go +++ b/crypto/storage/spi/mock.go @@ -22,6 +22,7 @@ import ( type MockStorage struct { ctrl *gomock.Controller recorder *MockStorageMockRecorder + isgomock struct{} } // MockStorageMockRecorder is the mock recorder for MockStorage. diff --git a/crypto/storage/vault/vault.go b/crypto/storage/vault/vault.go index 4e19509512..62555de586 100644 --- a/crypto/storage/vault/vault.go +++ b/crypto/storage/vault/vault.go @@ -34,7 +34,6 @@ import ( const privateKeyPathName = "nuts-private-keys" const defaultPathPrefix = "kv" -const keyName = "key" // StorageType is the name of this storage type, used in health check reports and configuration. const StorageType = "vaultkv" diff --git a/didman/api/v1/client.go b/didman/api/v1/client.go index 6b2b1cd877..9a888b8bf1 100644 --- a/didman/api/v1/client.go +++ b/didman/api/v1/client.go @@ -32,7 +32,7 @@ type HTTPClient struct { } func (hb HTTPClient) client() ClientInterface { - response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateHTTPClient(hb.ClientConfig, hb.TokenGenerator))) + response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateInternalHTTPClient(hb.ClientConfig, hb.TokenGenerator))) if err != nil { panic(err) } diff --git a/didman/api/v1/generated.go b/didman/api/v1/generated.go index 18f19f4cdc..5d84dfb660 100644 --- a/didman/api/v1/generated.go +++ b/didman/api/v1/generated.go @@ -1,6 +1,6 @@ // Package v1 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v1 import ( diff --git a/didman/mock.go b/didman/mock.go index 08dbe8404b..6cc009d726 100644 --- a/didman/mock.go +++ b/didman/mock.go @@ -23,6 +23,7 @@ import ( type MockDidman struct { ctrl *gomock.Controller recorder *MockDidmanMockRecorder + isgomock struct{} } // MockDidmanMockRecorder is the mock recorder for MockDidman. @@ -209,6 +210,7 @@ func (mr *MockDidmanMockRecorder) UpdateEndpoint(ctx, id, serviceType, endpoint type MockCompoundServiceResolver struct { ctrl *gomock.Controller recorder *MockCompoundServiceResolverMockRecorder + isgomock struct{} } // MockCompoundServiceResolverMockRecorder is the mock recorder for MockCompoundServiceResolver. diff --git a/discovery/api/server/api.go b/discovery/api/server/api.go index dd519d4439..fe8b3d2b7b 100644 --- a/discovery/api/server/api.go +++ b/discovery/api/server/api.go @@ -71,11 +71,12 @@ func (w *Wrapper) GetPresentations(ctx context.Context, request GetPresentations timestamp = *request.Params.Timestamp } - presentations, newTimestamp, err := w.Server.Get(contextWithForwardedHost(ctx), request.ServiceID, timestamp) + presentations, seed, newTimestamp, err := w.Server.Get(contextWithForwardedHost(ctx), request.ServiceID, timestamp) if err != nil { return nil, err } return GetPresentations200JSONResponse{ + Seed: seed, Entries: presentations, Timestamp: newTimestamp, }, nil diff --git a/discovery/api/server/api_test.go b/discovery/api/server/api_test.go index 9092aef46f..e04bd02f20 100644 --- a/discovery/api/server/api_test.go +++ b/discovery/api/server/api_test.go @@ -35,10 +35,11 @@ const serviceID = "wonderland" func TestWrapper_GetPresentations(t *testing.T) { lastTimestamp := 1 presentations := map[string]vc.VerifiablePresentation{} + seed := "seed" ctx := context.Background() t.Run("no timestamp", func(t *testing.T) { test := newMockContext(t) - test.server.EXPECT().Get(gomock.Any(), serviceID, 0).Return(presentations, lastTimestamp, nil) + test.server.EXPECT().Get(gomock.Any(), serviceID, 0).Return(presentations, seed, lastTimestamp, nil) response, err := test.wrapper.GetPresentations(ctx, GetPresentationsRequestObject{ServiceID: serviceID}) @@ -46,11 +47,12 @@ func TestWrapper_GetPresentations(t *testing.T) { require.IsType(t, GetPresentations200JSONResponse{}, response) assert.Equal(t, lastTimestamp, response.(GetPresentations200JSONResponse).Timestamp) assert.Equal(t, presentations, response.(GetPresentations200JSONResponse).Entries) + assert.Equal(t, seed, response.(GetPresentations200JSONResponse).Seed) }) t.Run("with timestamp", func(t *testing.T) { givenTimestamp := 1 test := newMockContext(t) - test.server.EXPECT().Get(gomock.Any(), serviceID, 1).Return(presentations, lastTimestamp, nil) + test.server.EXPECT().Get(gomock.Any(), serviceID, 1).Return(presentations, seed, lastTimestamp, nil) response, err := test.wrapper.GetPresentations(ctx, GetPresentationsRequestObject{ ServiceID: serviceID, @@ -66,7 +68,7 @@ func TestWrapper_GetPresentations(t *testing.T) { }) t.Run("error", func(t *testing.T) { test := newMockContext(t) - test.server.EXPECT().Get(gomock.Any(), serviceID, 0).Return(nil, 0, errors.New("foo")) + test.server.EXPECT().Get(gomock.Any(), serviceID, 0).Return(nil, "", 0, errors.New("foo")) _, err := test.wrapper.GetPresentations(ctx, GetPresentationsRequestObject{ServiceID: serviceID}) diff --git a/discovery/api/server/client/http.go b/discovery/api/server/client/http.go index a9f33877bc..b819be4c92 100644 --- a/discovery/api/server/client/http.go +++ b/discovery/api/server/client/http.go @@ -21,7 +21,6 @@ package client import ( "bytes" "context" - "crypto/tls" "encoding/json" "fmt" "github.com/nuts-foundation/go-did/vc" @@ -35,9 +34,9 @@ import ( ) // New creates a new DefaultHTTPClient. -func New(strictMode bool, timeout time.Duration, tlsConfig *tls.Config) *DefaultHTTPClient { +func New(timeout time.Duration) *DefaultHTTPClient { return &DefaultHTTPClient{ - client: client.NewWithTLSConfig(timeout, tlsConfig), + client: client.New(timeout), } } @@ -65,33 +64,49 @@ func (h DefaultHTTPClient) Register(ctx context.Context, serviceEndpointURL stri } defer httpResponse.Body.Close() if err := core.TestResponseCodeWithLog(201, httpResponse, log.Logger()); err != nil { - return fmt.Errorf("non-OK response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + httpErr := err.(core.HttpError) // TestResponseCodeWithLog always returns an HttpError + return fmt.Errorf("non-OK response from remote Discovery Service (url=%s): %s", serviceEndpointURL, problemResponseToError(httpErr)) } return nil } -func (h DefaultHTTPClient) Get(ctx context.Context, serviceEndpointURL string, timestamp int) (map[string]vc.VerifiablePresentation, int, error) { +func (h DefaultHTTPClient) Get(ctx context.Context, serviceEndpointURL string, timestamp int) (map[string]vc.VerifiablePresentation, string, int, error) { httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceEndpointURL, nil) httpRequest.URL.RawQuery = url.Values{"timestamp": []string{fmt.Sprintf("%d", timestamp)}}.Encode() if err != nil { - return nil, 0, err + return nil, "", 0, err } httpRequest.Header.Set("X-Forwarded-Host", httpRequest.Host) // prevent cycles httpResponse, err := h.client.Do(httpRequest) if err != nil { - return nil, 0, fmt.Errorf("failed to invoke remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + return nil, "", 0, fmt.Errorf("failed to invoke remote Discovery Service (url=%s): %w", serviceEndpointURL, err) } defer httpResponse.Body.Close() if err := core.TestResponseCode(200, httpResponse); err != nil { - return nil, 0, fmt.Errorf("non-OK response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + httpErr := err.(core.HttpError) // TestResponseCodeWithLog always returns an HttpError + return nil, "", 0, fmt.Errorf("non-OK response from remote Discovery Service (url=%s): %s", serviceEndpointURL, problemResponseToError(httpErr)) } responseData, err := io.ReadAll(httpResponse.Body) if err != nil { - return nil, 0, fmt.Errorf("failed to read response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + return nil, "", 0, fmt.Errorf("failed to read response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) } var result PresentationsResponse if err := json.Unmarshal(responseData, &result); err != nil { - return nil, 0, fmt.Errorf("failed to unmarshal response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) + return nil, "", 0, fmt.Errorf("failed to unmarshal response from remote Discovery Service (url=%s): %w", serviceEndpointURL, err) } - return result.Entries, result.Timestamp, nil + return result.Entries, result.Seed, result.Timestamp, nil +} + +// problemResponseToError converts a Problem Details response to an error. +// It creates an error with the given string concatenated with the title and detail fields of the problem details. +func problemResponseToError(httpErr core.HttpError) string { + var problemDetails struct { + Title string `json:"title"` + Description string `json:"detail"` + Status int `json:"status"` + } + if err := json.Unmarshal(httpErr.ResponseBody, &problemDetails); err != nil { + return fmt.Sprintf("%s: %s", httpErr.Error(), httpErr.ResponseBody) + } + return fmt.Sprintf("server returned HTTP status code %d: %s: %s", problemDetails.Status, problemDetails.Title, problemDetails.Description) } diff --git a/discovery/api/server/client/http_test.go b/discovery/api/server/client/http_test.go index 9387dc5fd6..ac40dd8552 100644 --- a/discovery/api/server/client/http_test.go +++ b/discovery/api/server/client/http_test.go @@ -40,7 +40,7 @@ func TestHTTPInvoker_Register(t *testing.T) { t.Run("ok", func(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusCreated} server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) err := client.Register(context.Background(), server.URL, vp) @@ -49,13 +49,25 @@ func TestHTTPInvoker_Register(t *testing.T) { assert.Equal(t, "application/json", handler.Request.Header.Get("Content-Type")) assert.Equal(t, vpData, handler.RequestData) }) - t.Run("non-ok", func(t *testing.T) { - server := httptest.NewServer(&testHTTP.Handler{StatusCode: http.StatusInternalServerError}) - client := New(false, time.Minute, server.TLS) + t.Run("non-ok with problem details", func(t *testing.T) { + server := httptest.NewServer(&testHTTP.Handler{StatusCode: http.StatusBadRequest, ResponseData: `{"title":"missing credentials", "status":400, "detail":"could not resolve DID"}`}) + client := New(time.Minute) err := client.Register(context.Background(), server.URL, vp) assert.ErrorContains(t, err, "non-OK response from remote Discovery Service") + assert.ErrorContains(t, err, "server returned HTTP status code 400") + assert.ErrorContains(t, err, "missing credentials: could not resolve DID") + }) + t.Run("non-ok other", func(t *testing.T) { + server := httptest.NewServer(&testHTTP.Handler{StatusCode: http.StatusNotFound, ResponseData: `not found`}) + client := New(time.Minute) + + err := client.Register(context.Background(), server.URL, vp) + + assert.ErrorContains(t, err, "non-OK response from remote Discovery Service") + assert.ErrorContains(t, err, "server returned HTTP 404") + assert.ErrorContains(t, err, "not found") }) } @@ -67,29 +79,32 @@ func TestHTTPInvoker_Get(t *testing.T) { t.Run("no timestamp from client", func(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusOK} handler.ResponseData = map[string]interface{}{ + "seed": "seed", "entries": map[string]interface{}{"1": vp}, "timestamp": 1, } server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) - presentations, timestamp, err := client.Get(context.Background(), server.URL, 0) + presentations, seed, timestamp, err := client.Get(context.Background(), server.URL, 0) assert.NoError(t, err) assert.Len(t, presentations, 1) assert.Equal(t, "0", handler.RequestQuery.Get("timestamp")) assert.Equal(t, 1, timestamp) + assert.Equal(t, "seed", seed) }) t.Run("timestamp provided by client", func(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusOK} handler.ResponseData = map[string]interface{}{ + "seed": "seed", "entries": map[string]interface{}{"1": vp}, "timestamp": 1, } server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) - presentations, timestamp, err := client.Get(context.Background(), server.URL, 1) + presentations, _, timestamp, err := client.Get(context.Background(), server.URL, 1) assert.NoError(t, err) assert.Len(t, presentations, 1) @@ -105,29 +120,31 @@ func TestHTTPInvoker_Get(t *testing.T) { writer.Write([]byte("{}")) } server := httptest.NewServer(http.HandlerFunc(handler)) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) - _, _, err := client.Get(context.Background(), server.URL, 0) + _, _, _, err := client.Get(context.Background(), server.URL, 0) require.NoError(t, err) assert.True(t, strings.HasPrefix(capturedRequest.Header.Get("X-Forwarded-Host"), "127.0.0.1")) }) t.Run("server returns invalid status code", func(t *testing.T) { - handler := &testHTTP.Handler{StatusCode: http.StatusInternalServerError} + handler := &testHTTP.Handler{StatusCode: http.StatusInternalServerError, ResponseData: `{"title":"internal server error", "status":500, "detail":"db not found"}`} server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) - _, _, err := client.Get(context.Background(), server.URL, 0) + _, _, _, err := client.Get(context.Background(), server.URL, 0) assert.ErrorContains(t, err, "non-OK response from remote Discovery Service") + assert.ErrorContains(t, err, "server returned HTTP status code 500") + assert.ErrorContains(t, err, "internal server error: db not found") }) t.Run("server does not return JSON", func(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusOK} handler.ResponseData = "not json" server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) - _, _, err := client.Get(context.Background(), server.URL, 0) + _, _, _, err := client.Get(context.Background(), server.URL, 0) assert.ErrorContains(t, err, "failed to unmarshal response from remote Discovery Service") }) diff --git a/discovery/api/server/client/interface.go b/discovery/api/server/client/interface.go index 24087f718f..10722ea8ce 100644 --- a/discovery/api/server/client/interface.go +++ b/discovery/api/server/client/interface.go @@ -31,5 +31,5 @@ type HTTPClient interface { // Get retrieves Verifiable Presentations from the remote Discovery Service, that were added since the given timestamp. // If the call succeeds it returns the Verifiable Presentations and the timestamp that was returned by the server. // If the given timestamp is 0, all Verifiable Presentations are retrieved. - Get(ctx context.Context, serviceEndpointURL string, timestamp int) (map[string]vc.VerifiablePresentation, int, error) + Get(ctx context.Context, serviceEndpointURL string, timestamp int) (map[string]vc.VerifiablePresentation, string, int, error) } diff --git a/discovery/api/server/client/mock.go b/discovery/api/server/client/mock.go index 2fe595a282..92bbd420d8 100644 --- a/discovery/api/server/client/mock.go +++ b/discovery/api/server/client/mock.go @@ -21,6 +21,7 @@ import ( type MockHTTPClient struct { ctrl *gomock.Controller recorder *MockHTTPClientMockRecorder + isgomock struct{} } // MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. @@ -41,13 +42,14 @@ func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { } // Get mocks base method. -func (m *MockHTTPClient) Get(ctx context.Context, serviceEndpointURL string, timestamp int) (map[string]vc.VerifiablePresentation, int, error) { +func (m *MockHTTPClient) Get(ctx context.Context, serviceEndpointURL string, timestamp int) (map[string]vc.VerifiablePresentation, string, int, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", ctx, serviceEndpointURL, timestamp) ret0, _ := ret[0].(map[string]vc.VerifiablePresentation) - ret1, _ := ret[1].(int) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(int) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 } // Get indicates an expected call of Get. diff --git a/discovery/api/server/client/types.go b/discovery/api/server/client/types.go index 89c17609ee..184a4a0a82 100644 --- a/discovery/api/server/client/types.go +++ b/discovery/api/server/client/types.go @@ -24,6 +24,8 @@ import "github.com/nuts-foundation/go-did/vc" type PresentationsResponse struct { // Entries contains mappings from timestamp (as string) to a VerifiablePresentation. Entries map[string]vc.VerifiablePresentation `json:"entries"` + // Seed is a unique value for the combination of serviceID and a server instance. + Seed string `json:"seed"` // Timestamp is the timestamp of the latest entry. It's not a unix timestamp but a Lamport Clock. Timestamp int `json:"timestamp"` } diff --git a/discovery/api/server/generated.go b/discovery/api/server/generated.go index f60cf260ad..48c0e0baa0 100644 --- a/discovery/api/server/generated.go +++ b/discovery/api/server/generated.go @@ -1,6 +1,6 @@ // Package server provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package server import ( diff --git a/discovery/api/v1/api.go b/discovery/api/v1/api.go index 6632c4ca5e..b05a56ee59 100644 --- a/discovery/api/v1/api.go +++ b/discovery/api/v1/api.go @@ -121,6 +121,9 @@ func (w *Wrapper) ActivateServiceForSubject(ctx context.Context, request Activat func (w *Wrapper) DeactivateServiceForSubject(ctx context.Context, request DeactivateServiceForSubjectRequestObject) (DeactivateServiceForSubjectResponseObject, error) { err := w.Client.DeactivateServiceForSubject(ctx, request.ServiceID, request.SubjectID) if err != nil { + if errors.Is(err, discovery.ErrPresentationRegistrationFailed) { + return DeactivateServiceForSubject202JSONResponse{Reason: err.Error()}, nil + } return nil, err } return DeactivateServiceForSubject200Response{}, nil @@ -132,19 +135,24 @@ func (w *Wrapper) GetServices(_ context.Context, _ GetServicesRequestObject) (Ge } func (w *Wrapper) GetServiceActivation(ctx context.Context, request GetServiceActivationRequestObject) (GetServiceActivationResponseObject, error) { - response := GetServiceActivation200JSONResponse{ - Status: ServiceStatusActive, - } + response := GetServiceActivation200JSONResponse{} activated, presentations, err := w.Client.GetServiceActivation(ctx, request.ServiceID, request.SubjectID) if err != nil { if !errors.As(err, &discovery.RegistrationRefreshError{}) { return nil, err } - response.Status = ServiceStatusError + response.Status = to.Ptr(ServiceStatusError) response.Error = to.Ptr(err.Error()) } response.Activated = activated - response.Vp = &presentations + if activated && response.Status == nil { + // only set if not already set to ServiceStatusError + response.Status = to.Ptr(ServiceStatusActive) + } + if presentations != nil { + // if presentations is nil this would add `"vp":null` to the response + response.Vp = &presentations + } return response, nil } diff --git a/discovery/api/v1/api_test.go b/discovery/api/v1/api_test.go index 578a47ddfc..3a831ac364 100644 --- a/discovery/api/v1/api_test.go +++ b/discovery/api/v1/api_test.go @@ -108,6 +108,19 @@ func TestWrapper_DeactivateServiceForSubject(t *testing.T) { assert.NoError(t, err) assert.IsType(t, DeactivateServiceForSubject200Response{}, response) }) + t.Run("server error", func(t *testing.T) { + test := newMockContext(t) + expectedErr := errors.Join(discovery.ErrPresentationRegistrationFailed, errors.New("custom error")) + test.client.EXPECT().DeactivateServiceForSubject(gomock.Any(), serviceID, subjectID).Return(expectedErr) + + response, err := test.wrapper.DeactivateServiceForSubject(nil, DeactivateServiceForSubjectRequestObject{ + ServiceID: serviceID, + SubjectID: subjectID, + }) + + assert.NoError(t, err) + assert.IsType(t, DeactivateServiceForSubject202JSONResponse{Reason: expectedErr.Error()}, response) + }) t.Run("error", func(t *testing.T) { test := newMockContext(t) test.client.EXPECT().DeactivateServiceForSubject(gomock.Any(), serviceID, subjectID).Return(errors.New("foo")) @@ -199,7 +212,7 @@ func TestWrapper_GetServiceActivation(t *testing.T) { assert.NoError(t, err) require.IsType(t, GetServiceActivation200JSONResponse{}, response) assert.True(t, response.(GetServiceActivation200JSONResponse).Activated) - assert.Equal(t, ServiceStatusActive, string(response.(GetServiceActivation200JSONResponse).Status)) + assert.Equal(t, ServiceStatusActive, *response.(GetServiceActivation200JSONResponse).Status) assert.Nil(t, response.(GetServiceActivation200JSONResponse).Error) assert.Empty(t, response.(GetServiceActivation200JSONResponse).Vp) }) @@ -215,7 +228,7 @@ func TestWrapper_GetServiceActivation(t *testing.T) { assert.NoError(t, err) require.IsType(t, GetServiceActivation200JSONResponse{}, response) assert.True(t, response.(GetServiceActivation200JSONResponse).Activated) - assert.Equal(t, ServiceStatusError, string(response.(GetServiceActivation200JSONResponse).Status)) + assert.Equal(t, ServiceStatusError, *response.(GetServiceActivation200JSONResponse).Status) assert.NotNil(t, response.(GetServiceActivation200JSONResponse).Error) assert.Empty(t, response.(GetServiceActivation200JSONResponse).Vp) }) diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go index 5beca050fb..69cde00bdb 100644 --- a/discovery/api/v1/generated.go +++ b/discovery/api/v1/generated.go @@ -1,6 +1,6 @@ // Package v1 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v1 import ( @@ -61,13 +61,13 @@ type ServerInterface interface { // Searches for presentations registered on the Discovery Service. // (GET /internal/discovery/v1/{serviceID}) SearchPresentations(ctx echo.Context, serviceID string, params SearchPresentationsParams) error - // Client API to deactivate the given subject from the Discovery Service. + // Remove a subject from the Discovery Service. // (DELETE /internal/discovery/v1/{serviceID}/{subjectID}) DeactivateServiceForSubject(ctx echo.Context, serviceID string, subjectID string) error // Retrieves the activation status of a subject on a Discovery Service. // (GET /internal/discovery/v1/{serviceID}/{subjectID}) GetServiceActivation(ctx echo.Context, serviceID string, subjectID string) error - // Client API to activate a subject on the specified Discovery Service. + // Activate a Discovery Service for a subject. // (POST /internal/discovery/v1/{serviceID}/{subjectID}) ActivateServiceForSubject(ctx echo.Context, serviceID string, subjectID string) error } @@ -325,24 +325,6 @@ func (response DeactivateServiceForSubject202JSONResponse) VisitDeactivateServic return json.NewEncoder(w).Encode(response) } -type DeactivateServiceForSubject400ApplicationProblemPlusJSONResponse 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 DeactivateServiceForSubject400ApplicationProblemPlusJSONResponse) VisitDeactivateServiceForSubjectResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - type DeactivateServiceForSubjectdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. @@ -381,10 +363,10 @@ type GetServiceActivation200JSONResponse struct { Error *string `json:"error,omitempty"` // Status Status of the activation. "active" or "error". - Status GetServiceActivation200JSONResponseStatus `json:"status"` + Status *GetServiceActivation200JSONResponseStatus `json:"status,omitempty"` // Vp List of VPs on the Discovery Service for the subject. One per DID method registered on the Service. - // The list can be empty even if activated==true if none of the DIDs of a subject is actually registered on the Discovery Service. + // The list is empty when status is "error". Vp *[]VerifiablePresentation `json:"vp,omitempty"` } @@ -434,42 +416,6 @@ func (response ActivateServiceForSubject200Response) VisitActivateServiceForSubj return nil } -type ActivateServiceForSubject400ApplicationProblemPlusJSONResponse 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 ActivateServiceForSubject400ApplicationProblemPlusJSONResponse) VisitActivateServiceForSubjectResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - -type ActivateServiceForSubject412ApplicationProblemPlusJSONResponse 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 ActivateServiceForSubject412ApplicationProblemPlusJSONResponse) VisitActivateServiceForSubjectResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(412) - - return json.NewEncoder(w).Encode(response) -} - type ActivateServiceForSubjectdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. @@ -499,13 +445,13 @@ type StrictServerInterface interface { // Searches for presentations registered on the Discovery Service. // (GET /internal/discovery/v1/{serviceID}) SearchPresentations(ctx context.Context, request SearchPresentationsRequestObject) (SearchPresentationsResponseObject, error) - // Client API to deactivate the given subject from the Discovery Service. + // Remove a subject from the Discovery Service. // (DELETE /internal/discovery/v1/{serviceID}/{subjectID}) DeactivateServiceForSubject(ctx context.Context, request DeactivateServiceForSubjectRequestObject) (DeactivateServiceForSubjectResponseObject, error) // Retrieves the activation status of a subject on a Discovery Service. // (GET /internal/discovery/v1/{serviceID}/{subjectID}) GetServiceActivation(ctx context.Context, request GetServiceActivationRequestObject) (GetServiceActivationResponseObject, error) - // Client API to activate a subject on the specified Discovery Service. + // Activate a Discovery Service for a subject. // (POST /internal/discovery/v1/{serviceID}/{subjectID}) ActivateServiceForSubject(ctx context.Context, request ActivateServiceForSubjectRequestObject) (ActivateServiceForSubjectResponseObject, error) } diff --git a/discovery/api/v1/types.go b/discovery/api/v1/types.go index e5db1857e5..2c3c738ee1 100644 --- a/discovery/api/v1/types.go +++ b/discovery/api/v1/types.go @@ -37,7 +37,7 @@ type GetServiceActivation200JSONResponseStatus string const ( // ServiceStatusActive is the status for an active service. - ServiceStatusActive = "active" + ServiceStatusActive GetServiceActivation200JSONResponseStatus = "active" // ServiceStatusError is the status for an inactive service. - ServiceStatusError = "error" + ServiceStatusError GetServiceActivation200JSONResponseStatus = "error" ) diff --git a/discovery/client.go b/discovery/client.go index cfefdf88d7..84ec5c300e 100644 --- a/discovery/client.go +++ b/discovery/client.go @@ -33,7 +33,9 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "slices" "strings" "time" @@ -41,43 +43,34 @@ import ( // clientRegistrationManager is a client component, responsible for managing registrations on a Discovery Service. // It can refresh registered Verifiable Presentations when they are about to expire. -type clientRegistrationManager interface { - activate(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error - deactivate(ctx context.Context, serviceID, subjectID string) error - // refresh checks which Verifiable Presentations that are about to expire, and should be refreshed on the Discovery Service. - refresh(ctx context.Context, now time.Time) error -} - -var _ clientRegistrationManager = &defaultClientRegistrationManager{} - -type defaultClientRegistrationManager struct { +type clientRegistrationManager struct { services map[string]ServiceDefinition store *sqlStore client client.HTTPClient vcr vcr.VCR subjectManager didsubject.Manager + didResolver resolver.DIDResolver + verifier presentationVerifier } -func newRegistrationManager(services map[string]ServiceDefinition, store *sqlStore, client client.HTTPClient, vcr vcr.VCR, subjectManager didsubject.Manager) *defaultClientRegistrationManager { - return &defaultClientRegistrationManager{ +func newRegistrationManager(services map[string]ServiceDefinition, store *sqlStore, client client.HTTPClient, vcr vcr.VCR, subjectManager didsubject.Manager, didResolver resolver.DIDResolver, verifier presentationVerifier) *clientRegistrationManager { + return &clientRegistrationManager{ services: services, store: store, client: client, vcr: vcr, subjectManager: subjectManager, + didResolver: didResolver, + verifier: verifier, } } -func (r *defaultClientRegistrationManager) activate(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error { - service, serviceExists := r.services[serviceID] - if !serviceExists { - return ErrServiceNotFound - } - subjectDIDs, err := r.subjectManager.ListDIDs(ctx, subjectID) +func (r *clientRegistrationManager) activate(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error { + service, subjectDIDs, err := r.getServiceAndSubject(ctx, serviceID, subjectID) if err != nil { return err } - // filter DIDs on DID methods supported by the service + // filter DIDs on DID methods supported by the service; len == 0 means all DID Methods are accepted if len(service.DIDMethods) > 0 { j := 0 for i, did := range subjectDIDs { @@ -88,8 +81,21 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service } subjectDIDs = subjectDIDs[:j] } + + // and filter by deactivated status + j := 0 + for i, did := range subjectDIDs { + _, _, err := r.didResolver.Resolve(did, nil) + // any temporary error, like db errors should not cause a deregister action, only ErrDeactivated + if err == nil || !errors.Is(err, resolver.ErrDeactivated) { + subjectDIDs[j] = subjectDIDs[i] + j++ + } + } + subjectDIDs = subjectDIDs[:j] + if len(subjectDIDs) == 0 { - return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID) + return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrNoSupportedDIDMethods, subjectID) } log.Logger().Debugf("Registering Verifiable Presentation on Discovery Service (service=%s, subject=%s)", service.ID, subjectID) @@ -99,7 +105,12 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service for _, subjectDID := range subjectDIDs { err := r.registerPresentation(ctx, subjectDID, service, parameters) if err != nil { - loopErrs = append(loopErrs, fmt.Errorf("%s: %w", subjectDID.String(), err)) + if !errors.Is(err, pe.ErrNoCredentials) { // ignore missing credentials + loopErrs = append(loopErrs, fmt.Errorf("%s: %w", subjectDID.String(), err)) + } else { + // trace logging for missing credentials + log.Logger().Tracef("Missing credentials for Discovery Service (service=%s, subject=%s, did=%s): %s", service.ID, subjectID, subjectDID, err.Error()) + } } else { registeredDIDs = append(registeredDIDs, subjectDID.String()) } @@ -130,23 +141,18 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service return nil } -func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, serviceID, subjectID string) error { - service, serviceExists := r.services[serviceID] - if !serviceExists { - return ErrServiceNotFound - } - // delete DID/service combination from DB, so it won't be registered again - err := r.store.updatePresentationRefreshTime(serviceID, subjectID, nil, nil) +func (r *clientRegistrationManager) deactivate(ctx context.Context, serviceID, subjectID string) error { + service, subjectDIDs, err := r.getServiceAndSubject(ctx, serviceID, subjectID) if err != nil { return err } - // subject is now successfully deactivated for the service, anything after this point is best effort - subjectDIDs, err := r.subjectManager.ListDIDs(ctx, subjectID) + // delete DID/service combination from DB, so it won't be registered again + err = r.store.updatePresentationRefreshTime(serviceID, subjectID, nil, nil) if err != nil { - // this could be a didsubject.ErrSubjectNotFound after the subject has been deactivated - // still fail in this case since we no longer have the keys to sign a retraction return err } + // subject is now successfully deactivated for the service, + // anything after this point is best-effort and should include ErrPresentationRegistrationFailed to trigger a 202 status code // filter DIDs on DID methods supported by the service if len(service.DIDMethods) > 0 { @@ -161,7 +167,7 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi } if len(subjectDIDs) == 0 { // if this means we can't deactivate a previously registered subject because the DID methods have changed, then we rely on the refresh interval to clean up. - return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID) + return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrNoSupportedDIDMethods, subjectID) } // find all active presentations @@ -192,17 +198,30 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi return nil } -func (r *defaultClientRegistrationManager) deregisterPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, vp vc.VerifiablePresentation) error { +// getServiceAndSubject returns the service and subject, or ErrServiceNotFound / didsubject.ErrSubjectNotFound if either does not exist +func (r *clientRegistrationManager) getServiceAndSubject(ctx context.Context, serviceID, subjectID string) (ServiceDefinition, []did.DID, error) { + service, serviceExists := r.services[serviceID] + if !serviceExists { + return ServiceDefinition{}, nil, ErrServiceNotFound + } + subjectDIDs, err := r.subjectManager.ListDIDs(ctx, subjectID) + if err != nil { + return ServiceDefinition{}, nil, err + } + return service, subjectDIDs, nil +} + +func (r *clientRegistrationManager) deregisterPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, vp vc.VerifiablePresentation) error { presentation, err := r.buildPresentation(ctx, subjectDID, service, nil, map[string]interface{}{ "retract_jti": vp.ID.String(), - }) + }, &retractionPresentationType) if err != nil { return err } return r.client.Register(ctx, service.Endpoint, *presentation) } -func (r *defaultClientRegistrationManager) registerPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) error { +func (r *clientRegistrationManager) registerPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) error { presentation, err := r.findCredentialsAndBuildPresentation(ctx, subjectDID, service, parameters) if err != nil { return err @@ -210,7 +229,7 @@ func (r *defaultClientRegistrationManager) registerPresentation(ctx context.Cont return r.client.Register(ctx, service.Endpoint, *presentation) } -func (r *defaultClientRegistrationManager) findCredentialsAndBuildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) (*vc.VerifiablePresentation, error) { +func (r *clientRegistrationManager) findCredentialsAndBuildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) (*vc.VerifiablePresentation, error) { credentials, err := r.vcr.Wallet().List(ctx, subjectDID) if err != nil { return nil, err @@ -231,27 +250,18 @@ func (r *defaultClientRegistrationManager) findCredentialsAndBuildPresentation(c return nil, fmt.Errorf(errStr, service.ID, subjectDID, err) } - // add registration params as credential if not already done so by the Presentation Definition - var found bool - for _, cred := range matchingCredentials { - if cred.ID == registrationCredential.ID { - found = true - break - } - } - if !found { - matchingCredentials = append(matchingCredentials, credential.AutoCorrectSelfAttestedCredential(registrationCredential, subjectDID)) - } - - return r.buildPresentation(ctx, subjectDID, service, matchingCredentials, nil) + return r.buildPresentation(ctx, subjectDID, service, matchingCredentials, nil, nil) } -func (r *defaultClientRegistrationManager) buildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, - credentials []vc.VerifiableCredential, additionalProperties map[string]interface{}) (*vc.VerifiablePresentation, error) { +func (r *clientRegistrationManager) buildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, credentials []vc.VerifiableCredential, additionalProperties map[string]interface{}, additionalVPType *ssi.URI) (*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) holderURI := subjectDID.URI() + var additionalVPTypes []ssi.URI + if additionalVPType != nil { + additionalVPTypes = append(additionalVPTypes, *additionalVPType) + } return r.vcr.Wallet().BuildPresentation(ctx, credentials, holder.PresentationOptions{ ProofOptions: proof.ProofOptions{ Created: time.Now(), @@ -260,12 +270,14 @@ func (r *defaultClientRegistrationManager) buildPresentation(ctx context.Context Nonce: &nonce, AdditionalProperties: additionalProperties, }, - Format: vc.JWTPresentationProofFormat, - Holder: &holderURI, + AdditionalTypes: additionalVPTypes, + Format: vc.JWTPresentationProofFormat, + Holder: &holderURI, }, &subjectDID, false) } -func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time.Time) error { +// refresh checks which Verifiable Presentations that are about to expire, and should be refreshed on the Discovery Service. +func (r *clientRegistrationManager) refresh(ctx context.Context, now time.Time) error { log.Logger().Debug("Refreshing own registered Verifiable Presentations on Discovery Services") refreshCandidates, err := r.store.getSubjectsToBeRefreshed(now) if err != nil { @@ -275,11 +287,13 @@ func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time for _, candidate := range refreshCandidates { var loopErr error if err = r.activate(ctx, candidate.ServiceID, candidate.SubjectID, candidate.Parameters); err != nil { - if errors.Is(err, ErrDIDMethodsNotSupported) { + if errors.Is(err, ErrNoSupportedDIDMethods) { // DID method no longer supported, remove err = r.store.updatePresentationRefreshTime(candidate.ServiceID, candidate.SubjectID, nil, nil) if err != nil { loopErr = fmt.Errorf("failed to remove subject with unsupported DID method (service=%s, subject=%s): %w", candidate.ServiceID, candidate.SubjectID, err) + } else { + loopErr = fmt.Errorf("removed subject that has no supported DID method (service=%s, subject=%s)", candidate.ServiceID, candidate.SubjectID) } } else if errors.Is(err, didsubject.ErrSubjectNotFound) { // Subject has probably been deactivated. Remove from service or registration will be retried every refresh interval. @@ -305,6 +319,70 @@ func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time return nil } +// validate validates all presentations that are not yet validated +func (r *clientRegistrationManager) validate() error { + errMsg := "background verification of presentation failed (service: %s, id: %s)" + // find all unvalidated entries in store + presentations, err := r.store.allPresentations(false) + if err != nil { + return err + } + j := 0 + for i, presentation := range presentations { + verifiablePresentation, err := vc.ParseVerifiablePresentation(presentation.PresentationRaw) + if err != nil { + log.Logger().WithError(err).Warnf(errMsg, presentation.ServiceID, presentation.ID) + continue + } + service, exists := r.services[presentation.ServiceID] + if !exists { + log.Logger().WithError(err).Warnf("service not found for background validation: %s", presentation.ServiceID) + continue + } + if err = r.verifier(service, *verifiablePresentation); err != nil { + log.Logger().WithError(err).Warnf(errMsg, presentation.ServiceID, presentation.ID) + continue + } + presentations[j] = presentations[i] + j++ + } + // update flag in DB + if j > 0 { + return r.store.updateValidated(presentations[:j]) + } + return nil +} + +// removeRevoked removes all revoked presentations from the store +func (r *clientRegistrationManager) removeRevoked() error { + errMsg := "background revocation check of presentation failed (id: %s)" + // find all validated entries in store + presentations, err := r.store.allPresentations(true) + if err != nil { + return err + } + + for _, presentation := range presentations { + verifiablePresentation, err := vc.ParseVerifiablePresentation(presentation.PresentationRaw) + if err != nil { + log.Logger().WithError(err).Warnf(errMsg, presentation.ID) + continue + } + _, err = r.vcr.Verifier().VerifyVP(*verifiablePresentation, true, true, nil) + if err != nil && !errors.Is(err, types.ErrRevoked) { + log.Logger().WithError(err).Warnf(errMsg, presentation.ID) + continue + } + if errors.Is(err, types.ErrRevoked) { + log.Logger().WithError(err).Infof("removing revoked presentation (id: %s)", presentation.ID) + if err = r.store.deletePresentationRecord(presentation.ID); err != nil { + log.Logger().WithError(err).Warnf("failed to remove revoked presentation from discovery service (id: %s)", presentation.ID) + } + } + } + return nil +} + // clientUpdater is responsible for updating the local copy of Discovery Services // Callers should only call update(). type clientUpdater struct { @@ -342,18 +420,44 @@ func (u *clientUpdater) updateService(ctx context.Context, service ServiceDefini log.Logger(). WithField("discoveryService", service.ID). Tracef("Checking for new Verifiable Presentations from Discovery Service (timestamp: %d)", currentTimestamp) - presentations, serverTimestamp, err := u.client.Get(ctx, service.Endpoint, currentTimestamp) + presentations, seed, serverTimestamp, err := u.client.Get(ctx, service.Endpoint, currentTimestamp) if err != nil { return fmt.Errorf("failed to get presentations from discovery service (id=%s): %w", service.ID, err) } + // check testSeed in store, wipe if it's different. Done by the store for transaction safety. + err = u.store.wipeOnSeedChange(service.ID, seed) + if err != nil { + return fmt.Errorf("failed to wipe on testSeed change (service=%s, testSeed=%s): %w", service.ID, seed, err) + } for _, presentation := range presentations { - if err := u.verifier(service, presentation); err != nil { - log.Logger().WithError(err).Warnf("Presentation verification failed, not adding it (service=%s, id=%s)", service.ID, presentation.ID) + // Check if the presentation already exists + credentialSubjectID, err := credential.PresentationSigner(presentation) + if err != nil { + return err + } + exists, err := u.store.exists(service.ID, credentialSubjectID.String(), presentation.ID.String()) + if err != nil { + return err + } + if exists { continue } - if err := u.store.add(service.ID, presentation, serverTimestamp); err != nil { + + // always add the presentation, even if it's not valid + // it won't be returned in a search if invalid + // the validator will set the validated flag to true when it's valid + // it'll also remove it from the store if it's invalidated later + if record, err := u.store.add(service.ID, presentation, seed, serverTimestamp); err != nil { return fmt.Errorf("failed to store presentation (service=%s, id=%s): %w", service.ID, presentation.ID, err) + } else if err = u.verifier(service, presentation); err == nil { + // valid, immediately activate + if err = u.store.updateValidated([]presentationRecord{*record}); err != nil { + return fmt.Errorf("failed to update validated flag (service=%s, id=%s): %w", service.ID, presentation.ID, err) + } + } else { + log.Logger().WithError(err).Infof("failed to verify added presentation (service=%s, id=%s)", service.ID, presentation.ID) } + log.Logger(). WithField("discoveryService", service.ID). WithField("presentationID", presentation.ID). diff --git a/discovery/client_test.go b/discovery/client_test.go index 511bf20962..cbebab6add 100644 --- a/discovery/client_test.go +++ b/discovery/client_test.go @@ -31,7 +31,10 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/types" + "github.com/nuts-foundation/nuts-node/vcr/verifier" "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -41,17 +44,53 @@ import ( var nextRefresh = time.Now().Add(-1 * time.Hour) +type testContext struct { + ctrl *gomock.Controller + didResolver *resolver.MockDIDResolver + invoker *client.MockHTTPClient + vcr *vcr.MockVCR + wallet *holder.MockWallet + subjectManager *didsubject.MockManager + store *sqlStore + manager *clientRegistrationManager +} + +func newTestContext(t *testing.T) testContext { + t.Helper() + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + ctrl := gomock.NewController(t) + didResolver := resolver.NewMockDIDResolver(ctrl) + invoker := client.NewMockHTTPClient(ctrl) + vcr := vcr.NewMockVCR(ctrl) + wallet := holder.NewMockWallet(ctrl) + subjectManager := didsubject.NewMockManager(ctrl) + store := setupStore(t, storageEngine.GetSQLDatabase()) + manager := newRegistrationManager(testDefinitions(), store, invoker, vcr, subjectManager, didResolver, alwaysOkVerifier) + vcr.EXPECT().Wallet().Return(wallet).AnyTimes() + + return testContext{ + ctrl: ctrl, + didResolver: didResolver, + invoker: invoker, + vcr: vcr, + wallet: wallet, + subjectManager: subjectManager, + store: store, + manager: manager, + } +} + func Test_defaultClientRegistrationManager_activate(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) - 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).DoAndReturn(func(_ interface{}, credentials []vc.VerifiableCredential, options holder.PresentationOptions, _ interface{}, _ interface{}) (*vc.VerifiablePresentation, error) { + ctx := newTestContext(t) + ctx.invoker.EXPECT().Register(gomock.Any(), "http://example.com/usecase", vpAlice) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) + ctx.wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return([]vc.VerifiableCredential{vcAlice}, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).DoAndReturn(func(_ interface{}, credentials []vc.VerifiableCredential, options holder.PresentationOptions, _ interface{}, _ interface{}) (*vc.VerifiablePresentation, error) { // check if two credentials are given // check if the DiscoveryRegistrationCredential is added with an authServerURL assert.Len(t, credentials, 2) @@ -61,89 +100,65 @@ func Test_defaultClientRegistrationManager_activate(t *testing.T) { assert.Equal(t, aliceDID.String(), options.Holder.String()) return &vpAlice, nil }) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - err := manager.activate(audit.TestContext(), testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) + err := ctx.manager.activate(audit.TestContext(), testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) assert.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")) - 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 := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) + ctx.invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("invoker error")) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) + ctx.wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return([]vc.VerifiableCredential{vcAlice}, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - err := manager.activate(audit.TestContext(), testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) + err := ctx.manager.activate(audit.TestContext(), testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) require.ErrorIs(t, err, ErrPresentationRegistrationFailed) assert.ErrorContains(t, err, "invoker error") // check no refresh records are added - record, err := store.getPresentationRefreshRecord(testServiceID, aliceSubject) + record, err := ctx.store.getPresentationRefreshRecord(testServiceID, aliceSubject) require.NoError(t, err) assert.Nil(t, record) }) t.Run("DID method not supported", func(t *testing.T) { - ctrl := gomock.NewController(t) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - manager := newRegistrationManager(testDefinitions(), nil, nil, nil, mockSubjectManager) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - err := manager.activate(audit.TestContext(), unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) + err := ctx.manager.activate(audit.TestContext(), unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) - assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + assert.ErrorIs(t, err, ErrNoSupportedDIDMethods) }) t.Run("no matching credentials", func(t *testing.T) { - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - wallet := holder.NewMockWallet(ctrl) - wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, nil) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) + ctx.wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, nil) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - err := manager.activate(audit.TestContext(), testServiceID, aliceSubject, nil) + err := ctx.manager.activate(audit.TestContext(), testServiceID, aliceSubject, nil) require.ErrorIs(t, err, ErrPresentationRegistrationFailed) require.ErrorIs(t, err, pe.ErrNoCredentials) }) t.Run("subject with 2 DIDs, one registers and other fails", func(t *testing.T) { + ctx := newTestContext(t) subjectDIDs := []did.DID{aliceDID, bobDID} - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - invoker.EXPECT().Register(gomock.Any(), "http://example.com/usecase", vpAlice) - wallet := holder.NewMockWallet(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return(subjectDIDs, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) + ctx.didResolver.EXPECT().Resolve(bobDID, gomock.Any()).Return(nil, nil, nil) + ctx.invoker.EXPECT().Register(gomock.Any(), "http://example.com/usecase", vpAlice) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return(subjectDIDs, nil) // aliceDID registers - wallet.EXPECT().List(gomock.Any(), aliceDID).Return([]vc.VerifiableCredential{vcAlice}, nil) - wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &aliceDID, false).Return(&vpAlice, nil) + ctx.wallet.EXPECT().List(gomock.Any(), aliceDID).Return([]vc.VerifiableCredential{vcAlice}, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &aliceDID, false).Return(&vpAlice, nil) // bobDID has no credentials, so builds no presentation - wallet.EXPECT().List(gomock.Any(), bobDID).Return(nil, nil) + ctx.wallet.EXPECT().List(gomock.Any(), bobDID).Return(nil, nil) - err := manager.activate(audit.TestContext(), testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) + err := ctx.manager.activate(audit.TestContext(), testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) assert.NoError(t, err) }) @@ -158,48 +173,33 @@ func Test_defaultClientRegistrationManager_activate(t *testing.T) { PresentationMaxValidity: int((24 * time.Hour).Seconds()), }, } - 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(nil, nil) - wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).DoAndReturn(func(_ interface{}, credentials []vc.VerifiableCredential, _ interface{}, _ interface{}, _ interface{}) (*vc.VerifiablePresentation, error) { - // expect registration credential - assert.Len(t, credentials, 1) + ctx := newTestContext(t) + ctx.invoker.EXPECT().Register(gomock.Any(), "http://example.com/usecase", vpAlice) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) + ctx.wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).DoAndReturn(func(_ interface{}, credentials []vc.VerifiableCredential, _ interface{}, _ interface{}, _ interface{}) (*vc.VerifiablePresentation, error) { + assert.Len(t, credentials, 0) return &vpAlice, nil }) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(emptyDefinition, store, invoker, mockVCR, mockSubjectManager) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + ctx.manager = newRegistrationManager(emptyDefinition, ctx.store, ctx.invoker, ctx.vcr, ctx.subjectManager, ctx.didResolver, alwaysOkVerifier) - err := manager.activate(audit.TestContext(), testServiceID, aliceSubject, nil) + err := ctx.manager.activate(audit.TestContext(), testServiceID, aliceSubject, nil) assert.NoError(t, err) }) 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, nil) + ctx := newTestContext(t) - err := manager.activate(audit.TestContext(), "unknown", aliceSubject, nil) + err := ctx.manager.activate(audit.TestContext(), "unknown", aliceSubject, nil) assert.EqualError(t, err, "discovery service not found") }) t.Run("unknown subject", func(t *testing.T) { - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - store := setupStore(t, storageEngine.GetSQLDatabase()) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{}, didsubject.ErrSubjectNotFound) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{}, didsubject.ErrSubjectNotFound) - err := manager.activate(audit.TestContext(), testServiceID, aliceSubject, nil) + err := ctx.manager.activate(audit.TestContext(), testServiceID, aliceSubject, nil) assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) }) @@ -210,117 +210,92 @@ func Test_defaultClientRegistrationManager_deactivate(t *testing.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) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - err := manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) + err := ctx.manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) 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()) - wallet := holder.NewMockWallet(ctrl) - wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) - require.NoError(t, store.add(testServiceID, vpAlice, 1)) + ctx := newTestContext(t) + ctx.invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).DoAndReturn( + func(ctx context.Context, credentials []vc.VerifiableCredential, options holder.PresentationOptions, signerDID *did.DID, validateVC bool) (*vc.VerifiablePresentation, error) { + assert.Equal(t, options.AdditionalTypes[0], retractionPresentationType) + return &vpAlice, nil // not a revocation VP + }) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + _, err := ctx.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) - err := manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) + err = ctx.manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) assert.NoError(t, err) }) t.Run("already deactivated", func(t *testing.T) { - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(holder.NewMockWallet(ctrl)).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) vpAliceDeactivated := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) { claims[jwt.AudienceKey] = []string{testServiceID} claims["retract_jti"] = vpAlice.ID.String() vp.Type = append(vp.Type, retractionPresentationType) }, vcAlice) - require.NoError(t, store.add(testServiceID, vpAliceDeactivated, 1)) + _, err := ctx.store.add(testServiceID, vpAliceDeactivated, testSeed, 1) + require.NoError(t, err) - err := manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) + err = ctx.manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) assert.NoError(t, err) }) t.Run("DID method not supported", func(t *testing.T) { - ctrl := gomock.NewController(t) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, nil, nil, mockSubjectManager) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - err := manager.deactivate(audit.TestContext(), unsupportedServiceID, aliceSubject) + err := ctx.manager.deactivate(audit.TestContext(), unsupportedServiceID, aliceSubject) - assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + assert.ErrorIs(t, err, ErrNoSupportedDIDMethods) }) 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")) - wallet := holder.NewMockWallet(ctrl) - wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) - require.NoError(t, store.add(testServiceID, vpAlice, 1)) + ctx := newTestContext(t) + ctx.invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("remote error")) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(&vpAlice, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + _, err := ctx.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) - err := manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) + err = ctx.manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) require.ErrorIs(t, err, ErrPresentationRegistrationFailed) require.ErrorContains(t, err, "remote error") }) t.Run("building presentation fails", func(t *testing.T) { - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - wallet := holder.NewMockWallet(ctrl) - wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(nil, assert.AnError) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) - require.NoError(t, store.add(testServiceID, vpAlice, 1)) + ctx := newTestContext(t) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), false).Return(nil, assert.AnError) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + _, err := ctx.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) - err := manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) + err = ctx.manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) assert.ErrorIs(t, err, assert.AnError) }) t.Run("unknown subject", func(t *testing.T) { - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - store := setupStore(t, storageEngine.GetSQLDatabase()) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{}, didsubject.ErrSubjectNotFound) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{}, didsubject.ErrSubjectNotFound) - err := manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) + err := ctx.manager.deactivate(audit.TestContext(), testServiceID, aliceSubject) assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) }) + t.Run("unknown service", func(t *testing.T) { + ctx := newTestContext(t) + + err := ctx.manager.deactivate(audit.TestContext(), "unknown", aliceSubject) + + assert.ErrorIs(t, err, ErrServiceNotFound) + }) } func Test_defaultClientRegistrationManager_refresh(t *testing.T) { @@ -328,115 +303,188 @@ func Test_defaultClientRegistrationManager_refresh(t *testing.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) - mockSubjectManager := didsubject.NewMockManager(ctrl) - store := setupStore(t, storageEngine.GetSQLDatabase()) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx := newTestContext(t) - err := manager.refresh(audit.TestContext(), time.Now()) + err := ctx.manager.refresh(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) + ctx := newTestContext(t) + gomock.InOrder( + ctx.invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()), + ctx.invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("remote error")), + ) gomock.InOrder( - invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()), - invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("remote error")), + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil), + ctx.didResolver.EXPECT().Resolve(bobDID, gomock.Any()).Return(nil, nil, nil), ) - wallet := holder.NewMockWallet(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) // Alice - _ = store.updatePresentationRefreshTime(testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &nextRefresh) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - 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) + _ = ctx.store.updatePresentationRefreshTime(testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &nextRefresh) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &aliceDID, false).Return(&vpAlice, nil) + ctx.wallet.EXPECT().List(gomock.Any(), aliceDID).Return([]vc.VerifiableCredential{vcAlice}, nil) // Bob - _ = store.updatePresentationRefreshTime(testServiceID, bobSubject, defaultRegistrationParams(aliceSubject), &nextRefresh) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), bobSubject).Return([]did.DID{bobDID}, nil) - 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) + _ = ctx.store.updatePresentationRefreshTime(testServiceID, bobSubject, defaultRegistrationParams(aliceSubject), &nextRefresh) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), bobSubject).Return([]did.DID{bobDID}, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &bobDID, false).Return(&vpBob, nil) + ctx.wallet.EXPECT().List(gomock.Any(), bobDID).Return([]vc.VerifiableCredential{vcBob}, nil) - err := manager.refresh(audit.TestContext(), time.Now()) + err := ctx.manager.refresh(audit.TestContext(), time.Now()) errStr := "failed to refresh Verifiable Presentation (service=usecase_v1, subject=bob): registration of Verifiable Presentation on remote Discovery Service failed: did:example:bob: remote error" assert.EqualError(t, err, errStr) // check for presentationRefreshError - refreshError, err := store.getPresentationRefreshError(testServiceID, bobSubject) - require.NoError(t, err) + refreshError := getPresentationRefreshError(t, ctx.store.db, testServiceID, bobSubject) assert.Contains(t, refreshError.Error, errStr) }) t.Run("deactivate unknown subject", func(t *testing.T) { - store := setupStore(t, storageEngine.GetSQLDatabase()) - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return(nil, didsubject.ErrSubjectNotFound) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) - _ = store.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, &nextRefresh) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return(nil, didsubject.ErrSubjectNotFound) + _ = ctx.store.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, &nextRefresh) - err := manager.refresh(audit.TestContext(), time.Now()) + err := ctx.manager.refresh(audit.TestContext(), time.Now()) assert.EqualError(t, err, "removed unknown subject (service=usecase_v1, subject=alice)") }) t.Run("deactivate unsupported DID method", func(t *testing.T) { - store := setupStore(t, storageEngine.GetSQLDatabase()) - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockSubjectManager := didsubject.NewMockManager(ctrl) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) - _ = store.updatePresentationRefreshTime(unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &nextRefresh) + ctx := newTestContext(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) + _ = ctx.store.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, &nextRefresh) - err := manager.refresh(audit.TestContext(), time.Now()) + err := ctx.manager.refresh(audit.TestContext(), time.Now()) // refresh clears the registration - require.NoError(t, err) - record, err := store.getPresentationRefreshRecord(unsupportedServiceID, aliceSubject) + assert.EqualError(t, err, "removed subject that has no supported DID method (service=usecase_v1, subject=alice)") + record, err := ctx.store.getPresentationRefreshRecord(testServiceID, aliceSubject) assert.NoError(t, err) assert.Nil(t, record) }) t.Run("remove presentationRefreshError on success", func(t *testing.T) { - store := setupStore(t, storageEngine.GetSQLDatabase()) - ctrl := gomock.NewController(t) - invoker := client.NewMockHTTPClient(ctrl) + ctx := newTestContext(t) gomock.InOrder( - invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()), + ctx.invoker.EXPECT().Register(gomock.Any(), gomock.Any(), gomock.Any()), ) - wallet := holder.NewMockWallet(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Wallet().Return(wallet).AnyTimes() - mockSubjectManager := didsubject.NewMockManager(ctrl) - manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager) + ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) // Alice - _ = store.setPresentationRefreshError(testServiceID, aliceSubject, assert.AnError) - _ = store.updatePresentationRefreshTime(testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &time.Time{}) - mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - 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) + _ = ctx.store.setPresentationRefreshError(testServiceID, aliceSubject, assert.AnError) + _ = ctx.store.updatePresentationRefreshTime(testServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &time.Time{}) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), &aliceDID, false).Return(&vpAlice, nil) + ctx.wallet.EXPECT().List(gomock.Any(), aliceDID).Return([]vc.VerifiableCredential{vcAlice}, nil) - err := manager.refresh(audit.TestContext(), time.Now()) + err := ctx.manager.refresh(audit.TestContext(), time.Now()) require.NoError(t, err) // check for presentationRefreshError - refreshError, err := store.getPresentationRefreshError(testServiceID, aliceSubject) - require.NoError(t, err) + refreshError := getPresentationRefreshError(t, ctx.store.db, testServiceID, aliceSubject) assert.Nil(t, refreshError) }) } +func Test_defaultClientRegistrationManager_validate(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + tests := []struct { + name string + setupManager func(ctx testContext) *clientRegistrationManager + expectedLen int + }{ + { + name: "ok", + setupManager: func(ctx testContext) *clientRegistrationManager { + return ctx.manager + }, + expectedLen: 1, + }, + { + name: "verification failed", + setupManager: func(ctx testContext) *clientRegistrationManager { + return newRegistrationManager(testDefinitions(), ctx.store, ctx.invoker, ctx.vcr, ctx.subjectManager, ctx.didResolver, func(service ServiceDefinition, vp vc.VerifiablePresentation) error { + return errors.New("verification failed") + }) + }, + expectedLen: 0, + }, + { + name: "registration for unknown service", + setupManager: func(ctx testContext) *clientRegistrationManager { + return newRegistrationManager(map[string]ServiceDefinition{}, ctx.store, ctx.invoker, ctx.vcr, ctx.subjectManager, ctx.didResolver, alwaysOkVerifier) + }, + expectedLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newTestContext(t) + _, err := ctx.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) + manager := tt.setupManager(ctx) + + err = manager.validate() + require.NoError(t, err) + + presentations, err := ctx.store.allPresentations(true) + require.NoError(t, err) + assert.Len(t, presentations, tt.expectedLen) + }) + } +} + +func Test_defaultClientRegistrationManager_removeRevoked(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + tests := []struct { + name string + verifyVPError error + expectedLen int + }{ + { + name: "ok - not revoked", + verifyVPError: nil, + expectedLen: 1, + }, + { + name: "ok - revoked", + verifyVPError: types.ErrRevoked, + expectedLen: 0, + }, + { + name: "error", + verifyVPError: assert.AnError, + expectedLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newTestContext(t) + _, err := ctx.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) + require.NoError(t, ctx.manager.validate()) + + mockVerifier := verifier.NewMockVerifier(ctx.ctrl) + ctx.vcr.EXPECT().Verifier().Return(mockVerifier).AnyTimes() + mockVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Return(nil, tt.verifyVPError) + + err = ctx.manager.removeRevoked() + require.NoError(t, err) + + presentations, err := ctx.store.allPresentations(true) + require.NoError(t, err) + assert.Len(t, presentations, tt.expectedLen) + }) + } +} + func Test_clientUpdater_updateService(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) @@ -451,7 +499,7 @@ func Test_clientUpdater_updateService(t *testing.T) { httpClient := client.NewMockHTTPClient(ctrl) updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) - httpClient.EXPECT().Get(ctx, testDefinitions()[testServiceID].Endpoint, 0).Return(map[string]vc.VerifiablePresentation{}, 0, nil) + httpClient.EXPECT().Get(ctx, testDefinitions()[testServiceID].Endpoint, 0).Return(map[string]vc.VerifiablePresentation{}, testSeed, 0, nil) err := updater.updateService(ctx, testDefinitions()[testServiceID]) @@ -463,13 +511,23 @@ func Test_clientUpdater_updateService(t *testing.T) { httpClient := client.NewMockHTTPClient(ctrl) updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) - httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 0).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, 1, nil) + httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 0).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, testSeed, 1, nil) - err := updater.updateService(ctx, testDefinitions()[testServiceID]) + require.NoError(t, updater.updateService(ctx, testDefinitions()[testServiceID])) - require.NoError(t, err) + t.Run("ignores duplicates", func(t *testing.T) { + httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 1).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, testSeed, 1, nil) + + require.NoError(t, updater.updateService(ctx, testDefinitions()[testServiceID])) + + // check count + presentation, err := updater.store.allPresentations(true) + + require.NoError(t, err) + assert.Len(t, presentation, 1) + }) }) - t.Run("ignores invalid presentations", func(t *testing.T) { + t.Run("allows invalid presentations", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) ctrl := gomock.NewController(t) httpClient := client.NewMockHTTPClient(ctrl) @@ -480,45 +538,69 @@ func Test_clientUpdater_updateService(t *testing.T) { return nil }, httpClient) - httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 0).Return(map[string]vc.VerifiablePresentation{"1": vpAlice, "2": vpBob}, 2, nil) + httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 0).Return(map[string]vc.VerifiablePresentation{"1": vpAlice, "2": vpBob}, testSeed, 2, nil) err := updater.updateService(ctx, testDefinitions()[testServiceID]) require.NoError(t, err) - // Bob's VP should exist, Alice's not + // Both should exist, 1 should be validated immediately exists, err := store.exists(testServiceID, bobDID.String(), vpBob.ID.String()) require.NoError(t, err) require.True(t, exists) exists, err = store.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) require.NoError(t, err) - require.False(t, exists) + require.True(t, exists) + validated, err := store.allPresentations(true) + require.NoError(t, err) + require.Len(t, validated, 1) }) t.Run("pass timestamp", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) ctrl := gomock.NewController(t) httpClient := client.NewMockHTTPClient(ctrl) - err := store.setTimestamp(store.db, testServiceID, 1) + err := store.setTimestamp(store.db, testServiceID, testSeed, 1) + require.NoError(t, err) + updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) + + httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 1).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, testSeed, 1, nil) + + err = updater.updateService(ctx, testDefinitions()[testServiceID]) + require.NoError(t, err) + }) + t.Run("seed change wipes entries", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + httpClient := client.NewMockHTTPClient(ctrl) updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) + store.add(testServiceID, vpAlice, testSeed, 0) - httpClient.EXPECT().Get(ctx, serviceDefinition.Endpoint, 1).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, 1, nil) + exists, err := store.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) + require.NoError(t, err) + require.True(t, exists) + + httpClient.EXPECT().Get(ctx, testDefinitions()[testServiceID].Endpoint, 1).Return(map[string]vc.VerifiablePresentation{}, "other", 0, nil) err = updater.updateService(ctx, testDefinitions()[testServiceID]) require.NoError(t, err) + exists, err = store.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) + require.NoError(t, err) + require.False(t, exists) }) } func Test_clientUpdater_update(t *testing.T) { + seed := "seed" t.Run("proceeds when service update fails", func(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) store := setupStore(t, storageEngine.GetSQLDatabase()) ctrl := gomock.NewController(t) httpClient := client.NewMockHTTPClient(ctrl) - httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil) - httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, 0, errors.New("test")) - httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil) + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, seed, 0, nil) + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, "", 0, errors.New("test")) + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, seed, 0, nil) updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) err := updater.update(context.Background()) @@ -531,7 +613,7 @@ func Test_clientUpdater_update(t *testing.T) { store := setupStore(t, storageEngine.GetSQLDatabase()) ctrl := gomock.NewController(t) httpClient := client.NewMockHTTPClient(ctrl) - httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil).MinTimes(2) + httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, seed, 0, nil).MinTimes(2) updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient) err := updater.update(context.Background()) diff --git a/discovery/definition.go b/discovery/definition.go index fe84531bab..e032ae696e 100644 --- a/discovery/definition.go +++ b/discovery/definition.go @@ -50,7 +50,7 @@ type ServiceDefinition struct { ID string `json:"id"` // DIDMethods is a list of DID methods that are supported by the use case. // If empty, all methods are supported. - DIDMethods []string `json:"did_methods"` + DIDMethods []string `json:"did_methods,omitempty"` // Endpoint is the endpoint where the use case list is served. Endpoint string `json:"endpoint"` // PresentationDefinition specifies the Presentation ServiceDefinition submissions to the list must conform to, diff --git a/discovery/interface.go b/discovery/interface.go index f2522d2612..7e1d00e7bb 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -34,8 +34,12 @@ 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") +// ErrDIDMethodsNotSupported indicates that a received VP does not match the supported DID Methods of the service. var ErrDIDMethodsNotSupported = errors.New("DID methods not supported") +// ErrNoSupportedDIDMethods indicates that the client cannot create a VP for a subject because it has no (active) DID matching the supported DID Methods of the service. +var ErrNoSupportedDIDMethods = errors.New("subject has no (active) DIDs matching the service") + // authServerURLField is the field name for the authServerURL in the DiscoveryRegistrationCredential. // it is used to resolve authorization server metadata and thus the endpoints for a service entry. const authServerURLField = "authServerURL" @@ -48,26 +52,27 @@ type Server interface { Register(context context.Context, serviceID string, presentation vc.VerifiablePresentation) error // Get retrieves the presentations for the given service, starting from the given timestamp. // If the node is not configured as server for the given serviceID, the call will be forwarded to the configured server. - Get(context context.Context, serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, int, error) + Get(context context.Context, serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, string, int, error) } // Client defines the API for Discovery Clients. 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". + // It returns an ErrServiceNotFound if the service invalid/unknown. Search(serviceID string, query map[string]string) ([]SearchResult, error) // ActivateServiceForSubject causes a subject to be registered for a Discovery Service. // Registration of all DIDs of the subject will be attempted immediately, and automatically refreshed. // If the function is called again for the same service/DID combination, it will try to refresh the registration. // parameters are added as credentialSubject to a DiscoveryRegistrationCredential holder credential. - // It returns an error if the service or subject is invalid/unknown. + // It returns an ErrServiceNotFound or didsubject.ErrSubjectNotFound if the service or subject is invalid/unknown. ActivateServiceForSubject(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error // DeactivateServiceForSubject stops registration of a subject on a Discovery Service. // It also tries to remove all active registrations of the subject from the Discovery Service. // If removal of one or more active registration fails a ErrPresentationRegistrationFailed may be returned. The failed registrations will be removed when they expire. - // It returns an error if the service or subject is invalid/unknown. + // It returns an ErrServiceNotFound or didsubject.ErrSubjectNotFound if the service or subject is invalid/unknown. DeactivateServiceForSubject(ctx context.Context, serviceID, subjectID string) error // Services returns the list of services that are registered on this client. @@ -76,7 +81,8 @@ type Client interface { // GetServiceActivation returns the activation status of a subject on a Discovery Service. // The boolean indicates whether the subject is activated on the Discovery Service (ActivateServiceForSubject() has been called). // It also returns the Verifiable Presentations for all DIDs of the subject that are registered on the Discovery Service, if any. - // It returns a refreshRecordError if the last refresh of the service failed (activation status and VPs are still returned). + // It returns an ErrServiceNotFound or didsubject.ErrSubjectNotFound if the service or subject is invalid/unknown. + // It returns a RegistrationRefreshError with additional information if the last refresh of the service failed (activation status and VPs are still returned). // The time of the last error is added in the error message. GetServiceActivation(ctx context.Context, serviceID, subjectID string) (bool, []vc.VerifiablePresentation, error) } diff --git a/discovery/mock.go b/discovery/mock.go index 5466dd198e..bd78bca133 100644 --- a/discovery/mock.go +++ b/discovery/mock.go @@ -21,6 +21,7 @@ import ( type MockServer struct { ctrl *gomock.Controller recorder *MockServerMockRecorder + isgomock struct{} } // MockServerMockRecorder is the mock recorder for MockServer. @@ -41,13 +42,14 @@ func (m *MockServer) EXPECT() *MockServerMockRecorder { } // Get mocks base method. -func (m *MockServer) Get(context context.Context, serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, int, error) { +func (m *MockServer) Get(context context.Context, serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, string, int, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", context, serviceID, startAfter) ret0, _ := ret[0].(map[string]vc.VerifiablePresentation) - ret1, _ := ret[1].(int) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(int) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 } // Get indicates an expected call of Get. @@ -74,6 +76,7 @@ func (mr *MockServerMockRecorder) Register(context, serviceID, presentation any) type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder + isgomock struct{} } // MockClientMockRecorder is the mock recorder for MockClient. diff --git a/discovery/module.go b/discovery/module.go index aec286b936..c979fb9e72 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -32,6 +32,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/url" "os" "path" @@ -67,11 +68,12 @@ var _ Client = &Module{} var retractionPresentationType = ssi.MustParseURI("RetractedVerifiablePresentation") // New creates a new Module. -func New(storageInstance storage.Engine, vcrInstance vcr.VCR, subjectManager didsubject.Manager) *Module { +func New(storageInstance storage.Engine, vcrInstance vcr.VCR, subjectManager didsubject.Manager, didResolver resolver.DIDResolver) *Module { m := &Module{ storageInstance: storageInstance, vcrInstance: vcrInstance, subjectManager: subjectManager, + didResolver: didResolver, } m.ctx, m.cancel = context.WithCancel(context.Background()) m.routines = new(sync.WaitGroup) @@ -84,11 +86,12 @@ type Module struct { httpClient client.HTTPClient storageInstance storage.Engine store *sqlStore - registrationManager clientRegistrationManager + registrationManager *clientRegistrationManager serverDefinitions map[string]ServiceDefinition allDefinitions map[string]ServiceDefinition vcrInstance vcr.VCR subjectManager didsubject.Manager + didResolver resolver.DIDResolver clientUpdater *clientUpdater ctx context.Context cancel context.CancelFunc @@ -97,6 +100,19 @@ type Module struct { } func (m *Module) Configure(serverConfig core.ServerConfig) error { + var err error + m.publicURL, err = serverConfig.ServerURL() + if err != nil { + return err + } + + m.httpClient = client.New(serverConfig.HTTPClient.Timeout) + + return m.loadDefinitions() + +} + +func (m *Module) loadDefinitions() error { if m.config.Definitions.Directory == "" { return nil } @@ -110,11 +126,6 @@ func (m *Module) Configure(serverConfig core.ServerConfig) error { return fmt.Errorf("failed to load discovery defintions: %w", err) } - m.publicURL, err = serverConfig.ServerURL() - if err != nil { - return err - } - m.allDefinitions, err = loadDefinitions(m.config.Definitions.Directory) if err != nil { return err @@ -131,7 +142,6 @@ func (m *Module) Configure(serverConfig core.ServerConfig) error { } m.serverDefinitions = serverDefinitions } - m.httpClient = client.New(serverConfig.Strictmode, serverConfig.HTTPClient.Timeout, nil) return nil } @@ -142,7 +152,7 @@ func (m *Module) Start() error { return err } m.clientUpdater = newClientUpdater(m.allDefinitions, m.store, m.verifyRegistration, m.httpClient) - m.registrationManager = newRegistrationManager(m.allDefinitions, m.store, m.httpClient, m.vcrInstance, m.subjectManager) + m.registrationManager = newRegistrationManager(m.allDefinitions, m.store, m.httpClient, m.vcrInstance, m.subjectManager, m.didResolver, m.verifyRegistration) if m.config.Client.RefreshInterval > 0 { m.routines.Add(1) go func() { @@ -193,7 +203,28 @@ func (m *Module) Register(context context.Context, serviceID string, presentatio return err } - return m.store.add(serviceID, presentation, 0) + // Check if the presentation already exists + credentialSubjectID, err := credential.PresentationSigner(presentation) + if err != nil { + return err + } + exists, err := m.store.exists(definition.ID, credentialSubjectID.String(), presentation.ID.String()) + if err != nil { + return err + } + if exists { + return errors.Join(ErrInvalidPresentation, ErrPresentationAlreadyExists) + } + record, err := m.store.add(serviceID, presentation, "", 0) + if err != nil { + return err + } + // also update validated flag since validation is already done + if err = m.store.updateValidated([]presentationRecord{*record}); err != nil { + log.Logger().WithError(err).Errorf("failed to update validated flag for presentation (id: %s)", record.ID) + } + + return nil } func (m *Module) verifyRegistration(definition ServiceDefinition, presentation vc.VerifiablePresentation) error { @@ -225,15 +256,7 @@ func (m *Module) verifyRegistration(definition ServiceDefinition, presentation v return errors.Join(ErrInvalidPresentation, ErrDIDMethodsNotSupported) } - // Check if the presentation already exists - exists, err := m.store.exists(definition.ID, credentialSubjectID.String(), presentation.ID.String()) - if err != nil { - return err - } - if exists { - return errors.Join(ErrInvalidPresentation, ErrPresentationAlreadyExists) - } - // Depending on the presentation type, we need to validate different properties before storing it. + // Depending on the presentation type, we need to updateValidated different properties before storing it. if presentation.IsType(retractionPresentationType) { err = m.validateRetraction(definition.ID, presentation) } else { @@ -266,37 +289,21 @@ func (m *Module) validateRegistration(definition ServiceDefinition, presentation return fmt.Errorf("verifiable presentation doesn't match required presentation definition: %w", err) } if len(creds) != len(presentation.VerifiableCredential) { - // it could be the case that the VP contains a registration credential and the matching credentials do not. - // only return errPresentationDoesNotFulfillDefinition if both contain the registration credential or neither do. - vpContainsRegistrationCredential := false - for _, cred := range presentation.VerifiableCredential { - if slices.Contains(cred.Type, credential.DiscoveryRegistrationCredentialTypeV1URI()) { - vpContainsRegistrationCredential = true - break - } - } - matchingContainsRegistrationCredential := false - for _, cred := range creds { - if slices.Contains(cred.Type, credential.DiscoveryRegistrationCredentialTypeV1URI()) { - matchingContainsRegistrationCredential = true - break - } - } - if vpContainsRegistrationCredential && !matchingContainsRegistrationCredential && len(presentation.VerifiableCredential)-len(creds) == 1 { - return nil - } - return errPresentationDoesNotFulfillDefinition } return nil } func (m *Module) validateRetraction(serviceID string, presentation vc.VerifiablePresentation) error { - // Presentation might be a retraction (deletion of an earlier credentialRecord) must contain no credentials, and refer to the VP being retracted by ID. - // If those conditions aren't met, we don't need to register the retraction. + // RFC022 §3.4:it MUST specify RetractedVerifiablePresentation as type, in addition to the VerifiablePresentation. + // presentation.IsType(retractionPresentationType) // satisfied by the switch one level up + + // RFC022 §3.4: it MUST NOT contain any credentials. if len(presentation.VerifiableCredential) > 0 { return errRetractionContainsCredentials } + + // RFC022 §3.4: it MUST contain a retract_jti JWT claim, containing the jti of the presentation to retract. // Check that the retraction refers to an existing presentation. // If not, it might've already been removed due to expiry or superseded by a newer presentation. retractJTIRaw, _ := presentation.JWT().Get("retract_jti") @@ -317,18 +324,18 @@ func (m *Module) validateRetraction(serviceID string, presentation vc.Verifiable // Get is a Discovery Server function that retrieves the presentations for the given service, starting at timestamp+1. // See interface.go for more information. -func (m *Module) Get(context context.Context, serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, int, error) { +func (m *Module) Get(context context.Context, serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, string, int, error) { _, exists := m.serverDefinitions[serviceID] if !exists { // forward to configured server service, exists := m.allDefinitions[serviceID] if !exists { - return nil, 0, ErrServiceNotFound + return nil, "", 0, ErrServiceNotFound } // check If X-Forwarded-Host header is set, if set it must not be the same as service.Endpoint if cycleDetected(context, service) { - return nil, 0, errCyclicForwardingDetected + return nil, "", 0, errCyclicForwardingDetected } log.Logger().Infof("Forwarding Get request to configured server (service=%s)", serviceID) @@ -383,7 +390,10 @@ func (m *Module) ActivateServiceForSubject(ctx context.Context, serviceID, subje } log.Logger().Infof("Successfully activated service for subject (subject=%s,service=%s)", subjectID, serviceID) - _ = m.clientUpdater.updateService(ctx, m.allDefinitions[serviceID]) + err = m.clientUpdater.updateService(ctx, m.allDefinitions[serviceID]) + if err != nil { + log.Logger().Infof("Failed to update local copy of Discovery Service (service=%s): %s", serviceID, err) + } return nil } @@ -405,6 +415,11 @@ func (m *Module) Services() []ServiceDefinition { // GetServiceActivation is a Discovery Client function that retrieves the activation status of a service for a subject. // See interface.go for more information. func (m *Module) GetServiceActivation(ctx context.Context, serviceID, subjectID string) (bool, []vc.VerifiablePresentation, error) { + // first check if the combination getServiceAndSubject to generate correct api returns + _, subjectDIDs, err := m.registrationManager.getServiceAndSubject(ctx, serviceID, subjectID) + if err != nil { + return false, nil, err + } refreshRecord, err := m.store.getPresentationRefreshRecord(serviceID, subjectID) if err != nil { return false, nil, err @@ -414,12 +429,6 @@ func (m *Module) GetServiceActivation(ctx context.Context, serviceID, subjectID } // subject is activated for service - subjectDIDs, err := m.subjectManager.ListDIDs(ctx, subjectID) - if err != nil { - // can only happen if DB is offline/corrupt, or between deactivating a subject and its next refresh on the service (didsubject.ErrSubjectNotFound) - return true, nil, err - } - vps2D, err := m.store.getSubjectVPsOnService(serviceID, subjectDIDs) if err != nil { return true, nil, err // DB err @@ -474,7 +483,7 @@ func (m *Module) Search(serviceID string, query map[string]string) ([]SearchResu if !exists { return nil, ErrServiceNotFound } - matchingVPs, err := m.store.search(serviceID, query) + matchingVPs, err := m.store.search(serviceID, query, false) if err != nil { return nil, err } @@ -547,6 +556,16 @@ func (m *Module) update() { if err != nil { log.Logger().WithError(err).Errorf("Failed to load latest Verifiable Presentations from Discovery Service") } + // updateValidated all presentations not yet validated + err = m.registrationManager.validate() + if err != nil { + log.Logger().WithError(err).Errorf("Failed to validate presentations") + } + // purge list + err = m.registrationManager.removeRevoked() + if err != nil { + log.Logger().WithError(err).Errorf("Failed to remove revoked presentations") + } } do() for { diff --git a/discovery/module_test.go b/discovery/module_test.go index 1005bbb8ed..52dc2c2ba6 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -35,9 +35,13 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/verifier" "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "gorm.io/gorm" + "sync" + "sync/atomic" "testing" "time" ) @@ -58,15 +62,24 @@ func Test_Module_Register(t *testing.T) { t.Run("registration", func(t *testing.T) { t.Run("ok", func(t *testing.T) { - m, testContext := setupModule(t, storageEngine) - testContext.verifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil) + m, testContext := setupModule(t, storageEngine, func(module *Module) { + module.config.Client.RefreshInterval = 0 + }) + testContext.verifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Times(2) err := m.Register(ctx, testServiceID, vpAlice) require.NoError(t, err) - _, timestamp, err := m.Get(ctx, testServiceID, 0) + _, seed, timestamp, err := m.Get(ctx, testServiceID, 0) require.NoError(t, err) assert.Equal(t, 1, timestamp) + assert.NotEmpty(t, seed) + + t.Run("already exists", func(t *testing.T) { + err = m.Register(ctx, testServiceID, vpAlice) + + assert.ErrorIs(t, err, ErrPresentationAlreadyExists) + }) }) t.Run("not a server", func(t *testing.T) { m, _ := setupModule(t, storageEngine, func(module *Module) { @@ -75,7 +88,7 @@ func Test_Module_Register(t *testing.T) { Endpoint: "https://example.com/someother", } mockhttpclient := module.httpClient.(*client.MockHTTPClient) - mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", gomock.Any()).Return(nil, 0, nil).AnyTimes() + mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", gomock.Any()).Return(nil, testSeed, 0, nil).AnyTimes() mockhttpclient.EXPECT().Register(gomock.Any(), "https://example.com/someother", vpAlice).Return(nil) }) @@ -90,19 +103,10 @@ func Test_Module_Register(t *testing.T) { err := m.Register(ctx, testServiceID, vpAlice) require.EqualError(t, err, "presentation is invalid for registration\npresentation verification failed: failed") - _, timestamp, err := m.Get(ctx, testServiceID, 0) + _, _, timestamp, err := m.Get(ctx, testServiceID, 0) require.NoError(t, err) assert.Equal(t, 0, timestamp) }) - t.Run("already exists", func(t *testing.T) { - m, testContext := setupModule(t, storageEngine) - testContext.verifier.EXPECT().VerifyVP(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) - - err := m.Register(ctx, testServiceID, vpAlice) - assert.NoError(t, err) - err = m.Register(ctx, testServiceID, vpAlice) - assert.ErrorIs(t, err, ErrPresentationAlreadyExists) - }) t.Run("valid for too long", func(t *testing.T) { m, _ := setupModule(t, storageEngine, func(module *Module) { def := module.allDefinitions[testServiceID] @@ -159,7 +163,7 @@ func Test_Module_Register(t *testing.T) { err := m.Register(ctx, testServiceID, otherVP) assert.ErrorIs(t, err, pe.ErrNoCredentials) - _, timestamp, _ := m.Get(ctx, testServiceID, 0) + _, _, timestamp, _ := m.Get(ctx, testServiceID, 0) assert.Equal(t, 0, timestamp) }) t.Run("unsupported DID method", func(t *testing.T) { @@ -183,7 +187,7 @@ func Test_Module_Register(t *testing.T) { Endpoint: "https://example.com/someother", } mockhttpclient := module.httpClient.(*client.MockHTTPClient) - mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", gomock.Any()).Return(nil, 0, nil).AnyTimes() + mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", gomock.Any()).Return(nil, testSeed, 0, nil).AnyTimes() }) ctx := context.WithValue(ctx, XForwardedHostContextKey{}, "https://example.com") @@ -199,7 +203,10 @@ func Test_Module_Register(t *testing.T) { claims[jwt.AudienceKey] = []string{testServiceID} }) t.Run("ok", func(t *testing.T) { - m, testContext := setupModule(t, storageEngine) + m, testContext := setupModule(t, storageEngine, func(module *Module) { + // disable updater + module.config.Client.RefreshInterval = 0 + }) testContext.verifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Times(2) err := m.Register(ctx, testServiceID, vpAlice) @@ -258,19 +265,22 @@ func Test_Module_Get(t *testing.T) { require.NoError(t, storageEngine.Start()) ctx := context.Background() t.Run("ok", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) - require.NoError(t, m.store.add(testServiceID, vpAlice, 0)) - presentations, timestamp, err := m.Get(ctx, testServiceID, 0) + m, _ := setupModule(t, storageEngine, func(module *Module) { + module.config.Client.RefreshInterval = 0 + }) + _, err := m.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) + presentations, seed, timestamp, err := m.Get(ctx, testServiceID, 0) assert.NoError(t, err) assert.Equal(t, map[string]vc.VerifiablePresentation{"1": vpAlice}, presentations) assert.Equal(t, 1, timestamp) - }) - t.Run("ok - retrieve delta", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) - require.NoError(t, m.store.add(testServiceID, vpAlice, 0)) - presentations, _, err := m.Get(ctx, testServiceID, 0) - require.NoError(t, err) - require.Len(t, presentations, 1) + assert.NotEmpty(t, seed) + + t.Run("ok - retrieve delta", func(t *testing.T) { + presentations, _, _, err := m.Get(ctx, testServiceID, 1) + require.NoError(t, err) + require.Len(t, presentations, 0) + }) }) t.Run("not a server for this service ID, call forwarded", func(t *testing.T) { m, _ := setupModule(t, storageEngine, func(module *Module) { @@ -279,14 +289,15 @@ func Test_Module_Get(t *testing.T) { Endpoint: "https://example.com/someother", } mockhttpclient := module.httpClient.(*client.MockHTTPClient) - mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", 0).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, 1, nil).AnyTimes() + mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", 0).Return(map[string]vc.VerifiablePresentation{"1": vpAlice}, "otherSeed", 1, nil).AnyTimes() }) - presentations, timestamp, err := m.Get(ctx, "someother", 0) + presentations, seed, timestamp, err := m.Get(ctx, "someother", 0) require.NoError(t, err) assert.Equal(t, 1, timestamp) assert.Len(t, presentations, 1) + assert.Equal(t, "otherSeed", seed) }) t.Run("not a server for this service ID, call forwarded, cycle detected", func(t *testing.T) { m, _ := setupModule(t, storageEngine, func(module *Module) { @@ -295,11 +306,11 @@ func Test_Module_Get(t *testing.T) { Endpoint: "https://example.com/someother", } mockhttpclient := module.httpClient.(*client.MockHTTPClient) - mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", 0).Return(nil, 0, nil).AnyTimes() + mockhttpclient.EXPECT().Get(gomock.Any(), "https://example.com/someother", 0).Return(nil, "", 0, nil).AnyTimes() }) ctx := context.WithValue(ctx, XForwardedHostContextKey{}, "https://example.com") - _, _, err := m.Get(ctx, "someother", 0) + _, _, _, err := m.Get(ctx, "someother", 0) assert.ErrorIs(t, err, errCyclicForwardingDetected) }) @@ -309,6 +320,7 @@ type mockContext struct { ctrl *gomock.Controller subjectManager *didsubject.MockManager verifier *verifier.MockVerifier + didResolver *resolver.MockDIDResolver } func setupModule(t *testing.T, storageInstance storage.Engine, visitors ...func(module *Module)) (*Module, mockContext) { @@ -318,14 +330,28 @@ func setupModule(t *testing.T, storageInstance storage.Engine, visitors ...func( mockVCR := vcr.NewMockVCR(ctrl) mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() mockSubjectManager := didsubject.NewMockManager(ctrl) - m := New(storageInstance, mockVCR, mockSubjectManager) + mockDIDResolver := resolver.NewMockDIDResolver(ctrl) + m := New(storageInstance, mockVCR, mockSubjectManager, mockDIDResolver) m.config = DefaultConfig() m.publicURL = test.MustParseURL("https://example.com") require.NoError(t, m.Configure(core.TestServerConfig())) + httpClient := client.NewMockHTTPClient(ctrl) - httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, 0, nil).AnyTimes() - httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(nil, 0, nil).AnyTimes() - httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(nil, 0, nil).AnyTimes() + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, testSeed, 0, nil).AnyTimes() + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(nil, testSeed, 0, nil).AnyTimes() + httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(nil, testSeed, 0, nil).AnyTimes() + // set seed in DB otherwise behaviour is unpredictable due to background processes + if m.store != nil { + require.NoError(t, m.store.db.Transaction(func(tx *gorm.DB) error { + service := serviceRecord{ + ID: testServiceID, + Seed: testSeed, + LastLamportTimestamp: 0, + } + return tx.Save(&service).Error + })) + } + m.httpClient = httpClient m.allDefinitions = testDefinitions() m.serverDefinitions = map[string]ServiceDefinition{ @@ -344,6 +370,7 @@ func setupModule(t *testing.T, storageInstance storage.Engine, visitors ...func( ctrl: ctrl, verifier: mockVerifier, subjectManager: mockSubjectManager, + didResolver: mockDIDResolver, } } @@ -405,15 +432,28 @@ func TestModule_Configure(t *testing.T) { _, err := loadDefinitions(config.Definitions.Directory) assert.ErrorContains(t, err, "unable to read definitions directory 'test/non_existent'") }) + t.Run("missing definitions directory", func(t *testing.T) { + config := Config{} + m := &Module{config: config} + err := m.Configure(serverConfig) + + require.NoError(t, err) + assert.NotNil(t, m.publicURL) + assert.NotNil(t, m.httpClient) + }) } 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) - - require.NoError(t, m.store.add(testServiceID, vpAlice, 0)) + m, ctx := setupModule(t, storageEngine, func(module *Module) { + module.config.Client.RefreshInterval = 0 + }) + ctx.verifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil) + _, err := m.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) + require.NoError(t, m.registrationManager.validate()) results, err := m.Search(testServiceID, map[string]string{ "credentialSubject.person.givenName": "Alice", @@ -423,7 +463,8 @@ func TestModule_Search(t *testing.T) { { Presentation: vpAlice, Fields: map[string]interface{}{ - "issuer_field": authorityDID, + "auth_server_url": "https://example.com/oauth2/alice", + "issuer_field": authorityDID, }, Parameters: defaultRegistrationParams(aliceSubject), }, @@ -441,29 +482,78 @@ func TestModule_Search(t *testing.T) { func TestModule_update(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) - t.Run("Start() initiates update", func(t *testing.T) { - _, _ = setupModule(t, storageEngine, func(module *Module) { - // we want to assert the job runs, so make it run very often to make the test faster - module.config.Client.RefreshInterval = 1 * time.Millisecond - // overwrite httpClient mock for custom behavior assertions (we want to know how often HttpClient.Get() was called) - httpClient := client.NewMockHTTPClient(gomock.NewController(t)) - // Get() should be called at least twice (times the number of Service Definitions), once for the initial run on startup, then again after the refresh interval - httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, 0, nil).MinTimes(2 * len(module.allDefinitions)) - module.httpClient = httpClient - }) - time.Sleep(10 * time.Millisecond) - }) - t.Run("update() runs on node startup", func(t *testing.T) { - _, _ = setupModule(t, storageEngine, func(module *Module) { - // we want to assert the job immediately executes on node startup, even if the refresh interval hasn't passed - module.config.Client.RefreshInterval = time.Hour - // overwrite httpClient mock for custom behavior assertions (we want to know how often HttpClient.Get() was called) - httpClient := client.NewMockHTTPClient(gomock.NewController(t)) - // update causes call to HttpClient.Get(), once for each Service Definition - httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, 0, nil).Times(len(module.allDefinitions)) - module.httpClient = httpClient + + tests := []struct { + name string + refreshInterval time.Duration + expectedHTTPCalls int + expectedVerifyVPCalls int + }{ + { + name: "Start() initiates update", + refreshInterval: time.Millisecond, + expectedHTTPCalls: 2, + expectedVerifyVPCalls: 4, + }, + { + name: "update() runs on node startup", + refreshInterval: time.Hour, + expectedHTTPCalls: 1, + expectedVerifyVPCalls: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + mockVerifier := verifier.NewMockVerifier(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() + m := New(storageEngine, mockVCR, nil, nil) + m.config = DefaultConfig() + m.publicURL = test.MustParseURL("https://example.com") + m.config.Client.RefreshInterval = tt.refreshInterval + require.NoError(t, m.Configure(core.TestServerConfig())) + m.allDefinitions = testDefinitions() + httpClient := client.NewMockHTTPClient(ctrl) + httpWg := sync.WaitGroup{} + httpWg.Add(tt.expectedHTTPCalls * len(m.allDefinitions)) + httpCounter := atomic.Int64{} + httpCounter.Add(int64(tt.expectedHTTPCalls * len(m.allDefinitions))) + httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_, _, _ interface{}) (map[string]vc.VerifiablePresentation, string, int, error) { + if httpCounter.Load() != int64(0) { + httpWg.Done() + httpCounter.Add(int64(-1)) + } + return nil, testSeed, 0, nil + }).MinTimes(tt.expectedHTTPCalls * len(m.allDefinitions)) + m.httpClient = httpClient + m.store, _ = newSQLStore(m.storageInstance.GetSQLDatabase(), m.allDefinitions) + vpWg := sync.WaitGroup{} + vpWg.Add(tt.expectedVerifyVPCalls) + vpCounter := atomic.Int64{} + vpCounter.Add(int64(tt.expectedVerifyVPCalls)) + mockVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).DoAndReturn(func(_, _, _, _ interface{}) ([]vc.VerifiableCredential, error) { + if vpCounter.Load() != int64(0) { + vpWg.Done() + vpCounter.Add(int64(-1)) + } + return nil, nil + }).MinTimes(tt.expectedVerifyVPCalls) + _, err := m.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) + + require.NoError(t, m.Start()) + + vpWg.Wait() + httpWg.Wait() + + t.Cleanup(func() { + _ = m.Shutdown() + }) }) - }) + } } func TestModule_ActivateServiceForSubject(t *testing.T) { @@ -474,7 +564,7 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { // overwrite httpClient mock for custom behavior assertions (we want to know how often HttpClient.Get() was called) httpClient := client.NewMockHTTPClient(gomock.NewController(t)) httpClient.EXPECT().Register(gomock.Any(), gomock.Any(), vpAlice).Return(nil) - httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, 0, nil) + httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", 0, nil) module.httpClient = httpClient // disable auto-refresh job to have deterministic assertions module.config.Client.RefreshInterval = 0 @@ -485,6 +575,7 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { wallet.EXPECT().List(gomock.Any(), gomock.Any()).Return([]vc.VerifiableCredential{vcAlice}, nil) wallet.EXPECT().BuildPresentation(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(&vpAlice, nil) testContext.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + testContext.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) err := m.ActivateServiceForSubject(context.Background(), testServiceID, aliceSubject, nil) @@ -497,7 +588,7 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { // overwrite httpClient mock for custom behavior assertions (we want to know how often HttpClient.Get() was called) httpClient := client.NewMockHTTPClient(gomock.NewController(t)) httpClient.EXPECT().Register(gomock.Any(), gomock.Any(), vpAlice).Return(nil) - httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, 0, nil) + httpClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", 0, nil) module.httpClient = httpClient // disable auto-refresh job to have deterministic assertions module.config.Client.RefreshInterval = 0 @@ -513,10 +604,11 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { subject := make([]credential.DiscoveryRegistrationCredentialSubject, 0) _ = credentials[1].UnmarshalCredentialSubject(&subject) assert.Equal(t, "value", subject[0]["test"]) - assert.Equal(t, "https://example.com/oauth2/alice", subject[0]["authServerURL"]) + assert.Equal(t, "https://nuts.nl/oauth2/alice", subject[0]["authServerURL"]) return &vpAlice, nil }) testContext.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + testContext.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) err := m.ActivateServiceForSubject(context.Background(), testServiceID, aliceSubject, map[string]interface{}{"test": "value"}) @@ -532,6 +624,17 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { require.EqualError(t, err, "subject not found") }) + t.Run("deactivated DID", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + m, testContext := setupModule(t, storageEngine) + testContext.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + testContext.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) + + err := m.ActivateServiceForSubject(context.Background(), testServiceID, aliceSubject, nil) + + assert.ErrorIs(t, err, ErrNoSupportedDIDMethods) + }) t.Run("ok, but couldn't register presentation -> maps to ErrRegistrationFailed", func(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) @@ -540,6 +643,7 @@ func TestModule_ActivateServiceForSubject(t *testing.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) testContext.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) + testContext.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, nil) err := m.ActivateServiceForSubject(context.Background(), testServiceID, aliceSubject, nil) @@ -562,7 +666,8 @@ func TestModule_GetServiceActivation(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) t.Run("not activated", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, ctx := setupModule(t, storageEngine) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) activated, presentation, err := m.GetServiceActivation(context.Background(), testServiceID, aliceSubject) @@ -583,11 +688,14 @@ func TestModule_GetServiceActivation(t *testing.T) { assert.Nil(t, presentation) }) t.Run("activated, with VP", func(t *testing.T) { - m, testContext := setupModule(t, storageEngine) + m, testContext := setupModule(t, storageEngine, func(module *Module) { + module.config.Client.RefreshInterval = 0 + }) testContext.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil).AnyTimes() next := time.Now() _ = m.store.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, &next) - _ = m.store.add(testServiceID, vpAlice, 0) + _, err := m.store.add(testServiceID, vpAlice, testSeed, 1) + require.NoError(t, err) activated, presentation, err := m.GetServiceActivation(context.Background(), testServiceID, aliceSubject) @@ -605,4 +713,23 @@ func TestModule_GetServiceActivation(t *testing.T) { assert.ErrorAs(t, err, &RegistrationRefreshError{}) }) }) + t.Run("service does not exist - 404", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) + + activated, presentation, err := m.GetServiceActivation(context.Background(), "unknown", "unknown") + + assert.ErrorIs(t, err, ErrServiceNotFound) + assert.False(t, activated) + assert.Nil(t, presentation) + }) + t.Run("subject does not exist - 404", func(t *testing.T) { + m, ctx := setupModule(t, storageEngine) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), "unknown").Return(nil, didsubject.ErrSubjectNotFound) + + activated, presentation, err := m.GetServiceActivation(context.Background(), testServiceID, "unknown") + + assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) + assert.False(t, activated) + assert.Nil(t, presentation) + }) } diff --git a/discovery/store.go b/discovery/store.go index dc1b4a1c3c..5dfba82986 100644 --- a/discovery/store.go +++ b/discovery/store.go @@ -19,12 +19,15 @@ package discovery import ( + "database/sql/driver" "encoding/json" "errors" "fmt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/credential/store" "slices" + "strconv" + "strings" "time" "github.com/google/uuid" @@ -38,6 +41,7 @@ import ( type serviceRecord struct { ID string `gorm:"primaryKey"` + Seed string LastLamportTimestamp int } @@ -47,6 +51,8 @@ func (s serviceRecord) TableName() string { var _ schema.Tabler = (*presentationRecord)(nil) +type SQLBool bool + type presentationRecord struct { ID string `gorm:"primaryKey"` ServiceID string @@ -55,6 +61,7 @@ type presentationRecord struct { PresentationID string PresentationRaw string PresentationExpiration int64 + Validated SQLBool Credentials []credentialRecord `gorm:"foreignKey:PresentationID;references:ID"` } @@ -62,6 +69,30 @@ func (s presentationRecord) TableName() string { return "discovery_presentation" } +func (b *SQLBool) Scan(value interface{}) error { + *b = false + if value != nil { + switch v := value.(type) { + case int64: + if v != 0 { + *b = true + } + } + } + return nil +} + +func (b SQLBool) Value() (driver.Value, error) { + if b { + return int64(1), nil + } + return int64(0), nil +} + +func (b SQLBool) Bool() bool { + return bool(b) +} + // credentialRecord is a Verifiable Credential, part of a presentation (entry) on a use case list. type credentialRecord struct { // ID is the unique identifier of the entry. @@ -89,7 +120,9 @@ type presentationRefreshRecord struct { // Parameters is a serialized JSON object containing parameters that should be used when registering the subject on the service. Parameters []byte // PresentationRefreshError is the error message that occurred during the refresh attempt. - PresentationRefreshError presentationRefreshError `gorm:"foreignKey:ServiceID,SubjectID"` + // It's loaded using a spearate query instead of using GORM's Preload, which fails on MS SQL Server if it spans multiple columns + // See https://github.com/nuts-foundation/nuts-node/issues/3442 + PresentationRefreshError presentationRefreshError `gorm:"-"` } // TableName returns the table name for this DTO. @@ -133,24 +166,28 @@ func newSQLStore(db *gorm.DB, clientDefinitions map[string]ServiceDefinition) (* // add adds a presentation to the list of presentations. // If the given timestamp is 0, the server will assign a timestamp. -func (s *sqlStore) add(serviceID string, presentation vc.VerifiablePresentation, timestamp int) error { +func (s *sqlStore) add(serviceID string, presentation vc.VerifiablePresentation, seed string, timestamp int) (*presentationRecord, error) { credentialSubjectID, err := credential.PresentationSigner(presentation) if err != nil { - return err + return nil, err } if err := s.prune(); err != nil { - return err + return nil, err } - return s.db.Transaction(func(tx *gorm.DB) error { + var newPresentation *presentationRecord + return newPresentation, s.db.Transaction(func(tx *gorm.DB) error { if timestamp == 0 { var newTs *int - newTs, err = s.incrementTimestamp(tx, serviceID) + if len(seed) == 0 { // default for server + seed = uuid.NewString() + } + newTs, err = s.incrementTimestamp(tx, serviceID, seed) if err != nil { return err } timestamp = *newTs } else { - err = s.setTimestamp(tx, serviceID, timestamp) + err = s.setTimestamp(tx, serviceID, seed, timestamp) if err != nil { return err } @@ -161,15 +198,16 @@ func (s *sqlStore) add(serviceID string, presentation vc.VerifiablePresentation, return err } - return storePresentation(tx, serviceID, timestamp, presentation) + newPresentation, err = storePresentation(tx, serviceID, timestamp, presentation) + return err }) } // storePresentation creates a presentationRecord from a VerifiablePresentation and stores it, with its credentials, in the database. -func storePresentation(tx *gorm.DB, serviceID string, timestamp int, presentation vc.VerifiablePresentation) error { +func storePresentation(tx *gorm.DB, serviceID string, timestamp int, presentation vc.VerifiablePresentation) (*presentationRecord, error) { credentialSubjectID, err := credential.PresentationSigner(presentation) if err != nil { - return err + return nil, err } newPresentation := presentationRecord{ @@ -186,7 +224,7 @@ func storePresentation(tx *gorm.DB, serviceID string, timestamp int, presentatio for _, verifiableCredential := range presentation.VerifiableCredential { cred, err := credentialStore.Store(tx, verifiableCredential) if err != nil { - return err + return nil, err } newPresentation.Credentials = append(newPresentation.Credentials, credentialRecord{ ID: uuid.NewString(), @@ -195,45 +233,54 @@ func storePresentation(tx *gorm.DB, serviceID string, timestamp int, presentatio }) } - return tx.Create(&newPresentation).Error + err = tx.Create(&newPresentation).Error + return &newPresentation, err } // get returns all presentations, registered on the given service, starting after the given timestamp. // It also returns the latest timestamp of the returned presentations. -func (s *sqlStore) get(serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, int, error) { +func (s *sqlStore) get(serviceID string, startAfter int) (map[string]vc.VerifiablePresentation, string, int, error) { var service serviceRecord if err := s.db.Find(&service, "id = ?", serviceID).Error; err != nil { - return nil, 0, fmt.Errorf("query service '%s': %w", serviceID, err) + return nil, "", 0, fmt.Errorf("query service '%s': %w", serviceID, err) } var rows []presentationRecord err := s.db.Order("lamport_timestamp ASC").Find(&rows, "service_id = ? AND lamport_timestamp > ?", serviceID, startAfter).Error if err != nil { - return nil, 0, fmt.Errorf("query service '%s': %w", serviceID, err) + return nil, "", 0, fmt.Errorf("query service '%s': %w", serviceID, err) } presentations := make(map[string]vc.VerifiablePresentation, len(rows)) for _, row := range rows { presentation, err := vc.ParseVerifiablePresentation(row.PresentationRaw) if err != nil { - return nil, 0, fmt.Errorf("parse presentation '%s' of service '%s': %w", row.PresentationID, serviceID, err) + return nil, "", 0, fmt.Errorf("parse presentation '%s' of service '%s': %w", row.PresentationID, serviceID, err) } presentations[fmt.Sprintf("%d", row.LamportTimestamp)] = *presentation } - return presentations, service.LastLamportTimestamp, nil + return presentations, service.Seed, service.LastLamportTimestamp, nil } // search searches for presentations, registered on the given service, matching the given query. // The query is a map of JSON paths and expected string values, matched against the presentation's credentials. // Wildcard matching is supported by prefixing or suffixing the value with an asterisk (*). // It returns the presentations which contain credentials that match the given query. -func (s *sqlStore) search(serviceID string, query map[string]string) ([]vc.VerifiablePresentation, error) { - stmt := s.db.Model(&presentationRecord{}). - Where("service_id = ?", serviceID). - Joins("inner join discovery_credential ON discovery_credential.presentation_id = discovery_presentation.id") - stmt = store.CredentialStore{}.BuildSearchStatement(stmt, "discovery_credential.credential_id", query) +func (s *sqlStore) search(serviceID string, query map[string]string, allowUnvalidated bool) ([]vc.VerifiablePresentation, error) { + // first only select columns also used in group by clause + // if the query is empty, there's no need to do a join + stmt := s.db.Model(&presentationRecord{}).Select("discovery_presentation.id"). + Where("service_id = ?", serviceID) + if !allowUnvalidated { + stmt = stmt.Where("validated != 0") + } + if len(query) > 0 { + stmt = applyQuery(stmt, query) + } + stmt = stmt.Group("discovery_presentation.id") var matches []presentationRecord - if err := stmt.Preload("Credentials").Preload("Credentials.Credential").Find(&matches).Error; err != nil { + main := s.db.Preload("Credentials").Preload("Credentials.Credential").Model(&presentationRecord{}).Where("id in (?)", stmt) + if err := main.Find(&matches).Error; err != nil { return nil, err } var results []vc.VerifiablePresentation @@ -250,14 +297,63 @@ func (s *sqlStore) search(serviceID string, query map[string]string) ([]vc.Verif return results, nil } -// incrementTimestamp increments the last_timestamp of the given service. -func (s *sqlStore) incrementTimestamp(tx *gorm.DB, serviceID string) (*int, error) { +// applyQuery is like vcr/credential/store/sql.go#BuildSearchStatement but for searching VPs a group by is needed which also requires a sub query +// at that point a generic search statement is not maintainable +func applyQuery(stmt *gorm.DB, query map[string]string) *gorm.DB { + propertyColumns := map[string]string{ + "id": "credential.id", + "issuer": "credential.issuer", + "type": "credential.type", + "credentialSubject.id": "credential.subject_id", + } + + stmt = stmt.Joins("inner join discovery_credential ON discovery_credential.presentation_id = discovery_presentation.id") + stmt = stmt.Joins("inner join credential ON credential.id = discovery_credential.credential_id") + numProps := 0 + for jsonPath, value := range query { + // sort out wildcard mode: prefix and postfix asterisks (*) are replaced with %, which then is used in a LIKE query. + // an asterisk is translated to IS NOT NULL + // Otherwise, exact match (=) is used. + var op = "= ?" + if strings.TrimSpace(value) == "*" { + op = "is not null" + value = "" + } else { + if strings.HasPrefix(value, "*") { + value = "%" + value[1:] + op = "LIKE ?" + } + // and or + if strings.HasSuffix(value, "*") { + value = value[:len(value)-1] + "%" + op = "LIKE ?" + } + } + if column := propertyColumns[jsonPath]; column != "" { + stmt = stmt.Where(column+" "+op, value) + } else { + // This property is not present as column, but indexed as key-value property. + // Multiple (inner) joins to filter on a dynamic number of properties to filter on is not pretty, but it works + alias := "p" + strconv.Itoa(numProps) + numProps++ + // for an IS NOT NULL query, the value is ignored + stmt = stmt.Joins("inner join credential_prop "+alias+" ON "+alias+".credential_id = credential.id AND "+alias+".path = ? AND "+alias+".value "+op, jsonPath, value) + } + } + return stmt +} + +// incrementTimestamp increments the last_timestamp of the given service. USed by server. +func (s *sqlStore) incrementTimestamp(tx *gorm.DB, serviceID string, seed string) (*int, error) { service, err := s.findAndLockService(tx, serviceID) if err != nil { return nil, err } service.ID = serviceID service.LastLamportTimestamp = service.LastLamportTimestamp + 1 + if len(service.Seed) == 0 { // first time this service is used, generate a new testSeed + service.Seed = seed + } if err := tx.Save(service).Error; err != nil { return nil, err @@ -265,14 +361,15 @@ func (s *sqlStore) incrementTimestamp(tx *gorm.DB, serviceID string) (*int, erro return &service.LastLamportTimestamp, nil } -// setTimestamp sets the last_timestamp of the given service. -func (s *sqlStore) setTimestamp(tx *gorm.DB, serviceID string, timestamp int) error { +// setTimestamp sets the last_timestamp of the given service. Used by clients. +func (s *sqlStore) setTimestamp(tx *gorm.DB, serviceID string, seed string, timestamp int) error { service, err := s.findAndLockService(tx, serviceID) if err != nil { return err } service.ID = serviceID service.LastLamportTimestamp = timestamp + service.Seed = seed return tx.Save(service).Error } @@ -330,6 +427,41 @@ func (s *sqlStore) removeExpired() (int, error) { return int(result.RowsAffected), nil } +// allPresentations returns all presentations, the validated param can be used to select validated or unvalidated presentations +func (s *sqlStore) allPresentations(validated bool) ([]presentationRecord, error) { + result := make([]presentationRecord, 0) + stmt := s.db + if validated { + stmt = stmt.Where("validated != 0") + } else { + stmt = stmt.Where("validated = 0") + } + err := stmt.Find(&result).Error + if err != nil { + return nil, err + } + return result, nil +} + +// updateValidated sets the validated flag for the given presentations +func (s *sqlStore) updateValidated(records []presentationRecord) error { + return s.db.Transaction(func(tx *gorm.DB) error { + for _, record := range records { + if err := tx.Model(&presentationRecord{}).Where("id = ?", record.ID).Update("validated", SQLBool(true)).Error; err != nil { + return err + } + } + return nil + }) +} + +// deletePresentationRecord removes a presentationRecord from the store based on its ID +func (s *sqlStore) deletePresentationRecord(id string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + return tx.Delete(&presentationRecord{}, "id = ?", id).Error + }) +} + // 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, subjectID string, parameters map[string]interface{}, nextRefresh *time.Time) error { @@ -353,12 +485,17 @@ func (s *sqlStore) updatePresentationRefreshTime(serviceID string, subjectID str func (s *sqlStore) getPresentationRefreshRecord(serviceID string, subjectID string) (*presentationRefreshRecord, error) { var row presentationRefreshRecord - if err := s.db.Preload("PresentationRefreshError").Find(&row, "service_id = ? AND subject_id = ?", serviceID, subjectID).Error; err != nil { + if err := s.db.Find(&row, "service_id = ? AND subject_id = ?", serviceID, subjectID).Error; err != nil { return nil, err } if row.NextRefresh == 0 { return nil, nil } + // Load presentationRefreshError using a spearate query instead of using GORM's Preload, which fails on MS SQL Server if it spans multiple columns + // See https://github.com/nuts-foundation/nuts-node/issues/3442 + if err := s.db.Find(&row.PresentationRefreshError, "service_id = ? AND subject_id = ?", serviceID, subjectID).Error; err != nil { + return nil, err + } return &row, nil } @@ -384,17 +521,6 @@ func (s *sqlStore) getSubjectsToBeRefreshed(now time.Time) ([]refreshCandidate, return result, nil } -func (s *sqlStore) getPresentationRefreshError(serviceID string, subjectID string) (*presentationRefreshError, error) { - var row presentationRefreshError - if err := s.db.Find(&row, "service_id = ? AND subject_id = ?", serviceID, subjectID).Error; err != nil { - return nil, err - } - if row.LastOccurrence == 0 { - return nil, nil - } - return &row, nil -} - func (s *sqlStore) setPresentationRefreshError(serviceID string, subjectID string, refreshErr error) error { return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Delete(&presentationRefreshError{}, "service_id = ? AND subject_id = ?", serviceID, subjectID).Error; err != nil { @@ -458,7 +584,7 @@ func (s *sqlStore) getSubjectVPsOnService(serviceID string, subjectDIDs []did.DI for _, subjectDID := range subjectDIDs { loopVPs, err := s.search(serviceID, map[string]string{ "credentialSubject.id": subjectDID.String(), - }) + }, true) if err != nil { return nil, err } @@ -496,3 +622,30 @@ func (s *sqlStore) getSubjectVPsOnService(serviceID string, subjectDIDs []did.DI } return result, nil } + +// wipeOnSeedChange wipes the store on a testSeed change. +func (s *sqlStore) wipeOnSeedChange(serviceID string, seed string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // get the service + service, err := s.findAndLockService(tx, serviceID) + if err != nil { + return err + } + if service.Seed != seed && len(service.Seed) > 0 { + log.Logger(). + WithField("serviceID", serviceID). + Warnf("Seed changed, wiping store (old: %s, new: %s)", service.Seed, seed) + + // wipe the store + if err = tx.Where("service_id = ?", serviceID).Delete(&presentationRecord{}).Error; err != nil { + return err + } + + // reset the testSeed and timestamp + service.Seed = seed + service.LastLamportTimestamp = 0 + return tx.Save(service).Error + } + return nil + }) +} diff --git a/discovery/store_test.go b/discovery/store_test.go index 0e6dae97ca..d1b73ef23b 100644 --- a/discovery/store_test.go +++ b/discovery/store_test.go @@ -45,21 +45,24 @@ func Test_sqlStore_exists(t *testing.T) { }) t.Run("non-empty list, no match (other subject and ID)", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpBob, 0)) + _, err := m.add(testServiceID, vpBob, testSeed, 0) + require.NoError(t, err) exists, err := m.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) assert.NoError(t, err) assert.False(t, exists) }) t.Run("non-empty list, no match (other list)", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) exists, err := m.exists("other", aliceDID.String(), vpAlice.ID.String()) assert.NoError(t, err) assert.False(t, exists) }) t.Run("non-empty list, match", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) exists, err := m.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) assert.NoError(t, err) assert.True(t, exists) @@ -72,13 +75,36 @@ func Test_sqlStore_add(t *testing.T) { t.Run("no credentials in presentation", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - err := m.add(testServiceID, createPresentation(aliceDID), 0) + _, err := m.add(testServiceID, createPresentation(aliceDID), testSeed, 0) assert.NoError(t, err) }) + t.Run("seed", func(t *testing.T) { + t.Run("passing seed updates last_seed", func(t *testing.T) { + m := setupStore(t, storageEngine.GetSQLDatabase()) + _, err := m.add(testServiceID, createPresentation(aliceDID), testSeed, 0) + require.NoError(t, err) + + _, seed, _, err := m.get(testServiceID, 0) + + require.NoError(t, err) + assert.Equal(t, testSeed, seed) + }) + t.Run("generated seed", func(t *testing.T) { + m := setupStore(t, storageEngine.GetSQLDatabase()) + _, err := m.add(testServiceID, createPresentation(aliceDID), "", 0) + require.NoError(t, err) + + _, seed, _, err := m.get(testServiceID, 0) + + require.NoError(t, err) + assert.Len(t, seed, 36) // uuid v4 + }) + }) + t.Run("passing timestamp updates last_timestamp", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - err := m.add(testServiceID, createPresentation(aliceDID), 1) + _, err := m.add(testServiceID, createPresentation(aliceDID), testSeed, 1) require.NoError(t, err) timestamp, err := m.getTimestamp(testServiceID) @@ -91,8 +117,10 @@ func Test_sqlStore_add(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) secondVP := createPresentation(aliceDID, vcAlice) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) - require.NoError(t, m.add(testServiceID, secondVP, 0)) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + _, err = m.add(testServiceID, secondVP, testSeed, 0) + require.NoError(t, err) // First VP should not exist exists, err := m.exists(testServiceID, aliceDID.String(), vpAlice.ID.String()) @@ -112,42 +140,50 @@ func Test_sqlStore_get(t *testing.T) { t.Run("empty list, 0 timestamp", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - presentations, timestamp, err := m.get(testServiceID, 0) + presentations, seed, timestamp, err := m.get(testServiceID, 0) assert.NoError(t, err) assert.Empty(t, presentations) assert.Equal(t, 0, timestamp) + assert.Empty(t, seed) }) t.Run("1 entry, 0 timestamp", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) - presentations, timestamp, err := m.get(testServiceID, 0) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + presentations, seed, timestamp, err := m.get(testServiceID, 0) assert.NoError(t, err) assert.Equal(t, map[string]vc.VerifiablePresentation{"1": vpAlice}, presentations) assert.Equal(t, 1, timestamp) + assert.Equal(t, testSeed, seed) }) t.Run("2 entries, 0 timestamp", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) - require.NoError(t, m.add(testServiceID, vpBob, 0)) - presentations, timestamp, err := m.get(testServiceID, 0) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + _, err = m.add(testServiceID, vpBob, testSeed, 0) + require.NoError(t, err) + presentations, _, timestamp, err := m.get(testServiceID, 0) assert.NoError(t, err) assert.Equal(t, map[string]vc.VerifiablePresentation{"1": vpAlice, "2": vpBob}, presentations) assert.Equal(t, 2, timestamp) }) t.Run("2 entries, start after first", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) - require.NoError(t, m.add(testServiceID, vpBob, 0)) - presentations, timestamp, err := m.get(testServiceID, 1) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + _, err = m.add(testServiceID, vpBob, testSeed, 0) + require.NoError(t, err) + presentations, _, timestamp, err := m.get(testServiceID, 1) assert.NoError(t, err) assert.Equal(t, map[string]vc.VerifiablePresentation{"2": vpBob}, presentations) assert.Equal(t, 2, timestamp) }) t.Run("2 entries, start at end", func(t *testing.T) { m := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, m.add(testServiceID, vpAlice, 0)) - require.NoError(t, m.add(testServiceID, vpBob, 0)) - presentations, timestamp, err := m.get(testServiceID, 2) + _, err := m.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + _, err = m.add(testServiceID, vpBob, testSeed, 0) + presentations, _, timestamp, err := m.get(testServiceID, 2) assert.NoError(t, err) assert.Equal(t, map[string]vc.VerifiablePresentation{}, presentations) assert.Equal(t, 2, timestamp) @@ -159,7 +195,7 @@ func Test_sqlStore_get(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - err := c.add(testServiceID, createPresentation(aliceDID, vcAlice), 0) + _, err := c.add(testServiceID, createPresentation(aliceDID, vcAlice), testSeed, 0) require.NoError(t, err) }() } @@ -177,7 +213,7 @@ func Test_sqlStore_search(t *testing.T) { t.Run("empty database", func(t *testing.T) { c := setupStore(t, storageEngine.GetSQLDatabase()) - actualVPs, err := c.search(testServiceID, map[string]string{}) + actualVPs, err := c.search(testServiceID, map[string]string{}, true) require.NoError(t, err) require.Len(t, actualVPs, 0) }) @@ -185,29 +221,64 @@ func Test_sqlStore_search(t *testing.T) { vps := []vc.VerifiablePresentation{vpAlice} c := setupStore(t, storageEngine.GetSQLDatabase()) for _, vp := range vps { - err := c.add(testServiceID, vp, 0) + _, err := c.add(testServiceID, vp, testSeed, 0) require.NoError(t, err) } actualVPs, err := c.search(testServiceID, map[string]string{ "credentialSubject.person.givenName": "Alice", - }) + }, true) require.NoError(t, err) require.Len(t, actualVPs, 1) assert.Equal(t, vpAlice.ID.String(), actualVPs[0].ID.String()) }) + t.Run("find all", func(t *testing.T) { + vps := []vc.VerifiablePresentation{vpAlice, vpBob} + c := setupStore(t, storageEngine.GetSQLDatabase()) + for _, vp := range vps { + _, err := c.add(testServiceID, vp, testSeed, 0) + require.NoError(t, err) + } + + actualVPs, err := c.search(testServiceID, map[string]string{}, true) + require.NoError(t, err) + require.Len(t, actualVPs, 2) + + t.Run("wildcard", func(t *testing.T) { + actualVPs, err = c.search(testServiceID, map[string]string{"credentialSubject.person.givenName": "*"}, true) + require.NoError(t, err) + require.Len(t, actualVPs, 2) + }) + t.Run("wildcard postfix", func(t *testing.T) { + actualVPs, err = c.search(testServiceID, map[string]string{"credentialSubject.person.givenName": "A*"}, true) + require.NoError(t, err) + require.Len(t, actualVPs, 1) + }) + t.Run("validated", func(t *testing.T) { + actualVPs, err = c.search(testServiceID, map[string]string{}, false) + require.NoError(t, err) + require.Len(t, actualVPs, 0) + }) + }) t.Run("not found", func(t *testing.T) { vps := []vc.VerifiablePresentation{vpAlice, vpBob} c := setupStore(t, storageEngine.GetSQLDatabase()) for _, vp := range vps { - err := c.add(testServiceID, vp, 0) + _, err := c.add(testServiceID, vp, testSeed, 0) require.NoError(t, err) } actualVPs, err := c.search(testServiceID, map[string]string{ "credentialSubject.person.givenName": "Charlie", - }) + }, true) require.NoError(t, err) require.Len(t, actualVPs, 0) + + t.Run("wildcard", func(t *testing.T) { + actualVPs, err = c.search(testServiceID, map[string]string{"credentialSubject.person.noName": "*"}, true) + require.NoError(t, err) + require.Len(t, actualVPs, 0) + }) + }) } @@ -305,21 +376,19 @@ func Test_sqlStore_setPresentationRefreshError(t *testing.T) { require.NoError(t, c.setPresentationRefreshError(testServiceID, aliceSubject, assert.AnError)) // Check if the error is stored - refreshError, err := c.getPresentationRefreshError(testServiceID, aliceSubject) + refreshError := getPresentationRefreshError(t, c.db, testServiceID, aliceSubject) - require.NoError(t, err) assert.Equal(t, refreshError.Error, assert.AnError.Error()) assert.True(t, refreshError.LastOccurrence > int(time.Now().Add(-1*time.Second).Unix())) }) - t.Run("delete", func(t *testing.T) { + t.Run("deletePresentationRecord", func(t *testing.T) { c := setupStore(t, storageEngine.GetSQLDatabase()) require.NoError(t, c.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, to.Ptr(time.Now().Add(time.Second)))) require.NoError(t, c.setPresentationRefreshError(testServiceID, aliceSubject, assert.AnError)) require.NoError(t, c.setPresentationRefreshError(testServiceID, aliceSubject, nil)) - refreshError, err := c.getPresentationRefreshError(testServiceID, aliceSubject) + refreshError := getPresentationRefreshError(t, c.db, testServiceID, aliceSubject) - require.NoError(t, err) assert.Nil(t, refreshError) }) } @@ -340,8 +409,10 @@ func Test_sqlStore_getSubjectVPsOnService(t *testing.T) { _ = storageEngine.Shutdown() }) c := setupStore(t, storageEngine.GetSQLDatabase()) - require.NoError(t, c.add(testServiceID, vpAlice2, 0)) - require.NoError(t, c.add(testServiceID, vpBob2, 0)) + _, err := c.add(testServiceID, vpAlice2, testSeed, 0) + require.NoError(t, err) + _, err = c.add(testServiceID, vpBob2, testSeed, 0) + require.NoError(t, err) t.Run("ok - single", func(t *testing.T) { vps, err := c.getSubjectVPsOnService(testServiceID, []did.DID{aliceDID}) @@ -355,6 +426,91 @@ func Test_sqlStore_getSubjectVPsOnService(t *testing.T) { }) } +func Test_sqlStore_wipeOnSeedChange(t *testing.T) { + logrus.SetLevel(logrus.DebugLevel) + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { + _ = storageEngine.Shutdown() + }) + + t.Run("empty database", func(t *testing.T) { + c := setupStore(t, storageEngine.GetSQLDatabase()) + err := c.wipeOnSeedChange(testServiceID, "other") + require.NoError(t, err) + }) + t.Run("1 entry wiped, 1 remains", func(t *testing.T) { + c := setupStore(t, storageEngine.GetSQLDatabase()) + _, err := c.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + _, err = c.add("other", vpAlice, testSeed, 0) + require.NoError(t, err) + + err = c.wipeOnSeedChange(testServiceID, "other") + require.NoError(t, err) + + vps, err := c.search(testServiceID, map[string]string{}, true) + require.NoError(t, err) + require.Len(t, vps, 0) + vps, err = c.search("other", map[string]string{}, true) + require.NoError(t, err) + require.Len(t, vps, 1) + }) +} + +func Test_sqlStore_updateValidated(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { + _ = storageEngine.Shutdown() + }) + + c := setupStore(t, storageEngine.GetSQLDatabase()) + _, err := c.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + + result, err := c.allPresentations(true) + require.NoError(t, err) + assert.Len(t, result, 0) + result, err = c.allPresentations(false) + require.NoError(t, err) + assert.Len(t, result, 1) + + t.Run("validated", func(t *testing.T) { + err = c.updateValidated(result) + require.NoError(t, err) + + result, err = c.allPresentations(false) + require.NoError(t, err) + assert.Len(t, result, 0) + result, err = c.allPresentations(true) + require.NoError(t, err) + assert.Len(t, result, 1) + }) +} + +func Test_sqlStore_delete(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + t.Cleanup(func() { + _ = storageEngine.Shutdown() + }) + + c := setupStore(t, storageEngine.GetSQLDatabase()) + _, err := c.add(testServiceID, vpAlice, testSeed, 0) + require.NoError(t, err) + presentations, _ := c.allPresentations(false) + require.Len(t, presentations, 1) + + err = c.deletePresentationRecord(presentations[0].ID) + + require.NoError(t, err) + + result, err := c.allPresentations(false) + require.NoError(t, err) + assert.Len(t, result, 0) +} + func setupStore(t *testing.T, db *gorm.DB) *sqlStore { resetStore(t, db) defs := testDefinitions() @@ -364,9 +520,19 @@ func setupStore(t *testing.T, db *gorm.DB) *sqlStore { } func resetStore(t *testing.T, db *gorm.DB) { - // related tables are emptied due to on-delete-cascade clause + // related tables are emptied due to on-deletePresentationRecord-cascade clause tableNames := []string{"discovery_service", "discovery_presentation", "discovery_credential", "credential", "credential_prop"} for _, tableName := range tableNames { require.NoError(t, db.Exec("DELETE FROM "+tableName).Error) } } + +func getPresentationRefreshError(t *testing.T, db *gorm.DB, serviceID string, subjectID string) *presentationRefreshError { + var row presentationRefreshError + err := db.Find(&row, "service_id = ? AND subject_id = ?", serviceID, subjectID).Error + require.NoError(t, err) + if row.LastOccurrence == 0 { + return nil + } + return &row +} diff --git a/discovery/test.go b/discovery/test.go index 222f2f9e20..9000bb497a 100644 --- a/discovery/test.go +++ b/discovery/test.go @@ -40,6 +40,8 @@ import ( "time" ) +const testSeed = "1234567890" + var keyPairs map[string]*ecdsa.PrivateKey var authorityDID did.DID var aliceSubject string @@ -100,6 +102,16 @@ func testDefinitions() map[string]ServiceDefinition { }, }, }, + }, { + Id: "2", + Constraints: &pe.Constraints{ + Fields: []pe.Field{ + { + Id: to.Ptr("auth_server_url"), + Path: []string{"$.credentialSubject.authServerURL"}, + }, + }, + }, }, }, }, diff --git a/docs/_static/auth/iam.partial.yaml b/docs/_static/auth/iam.partial.yaml index 5c3756a585..e8520fab41 100644 --- a/docs/_static/auth/iam.partial.yaml +++ b/docs/_static/auth/iam.partial.yaml @@ -208,9 +208,8 @@ paths: summary: Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. description: | The presentation definition (specified by https://identity.foundation/presentation-exchange/spec/v2.0.0/) is a JSON object that describes the desired verifiable credentials and presentation formats. - A presentation definition is matched against a wallet. If verifiable credentials matching the definition are found, - a presentation can created together with a presentation submission. - The API returns an array of definitions, one per scope/backend combination if applicable. + + It returns OAuth2 errors as specified by https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2, specifically: invalid_request and invalid_scope. operationId: presentationDefinition tags: - oauth2 @@ -241,8 +240,12 @@ paths: application/json: schema: "$ref": "#/components/schemas/PresentationDefinition" - "default": - $ref: '../common/error_response.yaml' + default: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" /oauth2/{subjectID}/response: post: summary: Used by wallets to post the authorization response or error to. diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index f772b31ef5..e6bd2aa5f0 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -14,9 +14,10 @@ paths: It'll initiate a s2s (RFC021) flow. error returns: - * 400 - one of the parameters has the wrong format or an OAuth error occurred - * 412 - the organization wallet does not contain the correct credentials - * 503 - the authorizer could not be reached or returned an error + * 400 - one of the parameters has the wrong format, an OAuth error occurred, or the http client calling the authorizer returned an error due to incorrect input + * 412 - the organization wallet does not contain the correct credentials or doesn't support the right DID methods + * 502 - the authorizer returned an error + * 503 - the authorizer could not be reached tags: - auth parameters: @@ -591,16 +592,16 @@ components: $ref: '#/components/schemas/cnf' iss: type: string - description: Contains the DID of the authorizer. Should be equal to 'sub' - example: did:web:example.com:resource-owner + description: Issuer URL of the authorizer. + example: https://example.com/oauth2/authorizer aud: type: string description: RFC7662 - Service-specific string identifier or list of string identifiers representing the intended audience for this token, as defined in JWT [RFC7519]. example: "https://target_token_endpoint" client_id: type: string - description: The client (DID) the access token was issued to - example: did:web:example.com:client + description: The client identity the access token was issued to. Since the Verifiable Presentation is used to grant access, the client_id reflects the client_id in the access token request. + example: https://example.com/oauth2/client exp: type: integer description: Expiration date in seconds since UNIX epoch diff --git a/docs/_static/discovery/server.yaml b/docs/_static/discovery/server.yaml index 4bf8547842..9e8b8ccbc0 100644 --- a/docs/_static/discovery/server.yaml +++ b/docs/_static/discovery/server.yaml @@ -76,9 +76,13 @@ components: PresentationsResponse: type: object required: + - seed - timestamp - entries properties: + seed: + description: unique value for the combination of serviceID and a server instance. + type: string timestamp: description: highest timestamp of the returned presentations, should be used as the timestamp for the next query type: integer diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index d56076d6f3..e8b597d4c8 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -70,6 +70,9 @@ paths: - `credentialSubject.organization.name=Hospital*` - `credentialSubject.organization.name=*clinic` - `issuer=did:web:example.com` + + error returns: + * 404 - unknown service. operationId: searchPresentations tags: - discovery @@ -111,6 +114,9 @@ paths: and the status of the activation. A refresh could have failed. It will return true after successfully calling the activateServiceForSubject API, and false after calling the deactivateServiceForSubject API. It also returns the active Verifiable Presentations, if any. + + error returns: + * 404 - unknown service or subject. operationId: getServiceActivation tags: - discovery @@ -123,7 +129,6 @@ paths: type: object required: - activated - - status properties: activated: type: boolean @@ -140,31 +145,30 @@ paths: vp: description: | List of VPs on the Discovery Service for the subject. One per DID method registered on the Service. - The list can be empty even if activated==true if none of the DIDs of a subject is actually registered on the Discovery Service. + The list is empty when status is "error". type: array items: $ref: "#/components/schemas/VerifiablePresentation" default: $ref: "../common/error_response.yaml" post: - summary: Client API to activate a subject on the specified Discovery Service. + summary: Activate a Discovery Service for a subject. description: | An API provided by the discovery client that will cause all qualifying DIDs of a subject to be registered on the specified Discovery Service. A DID qualifies for registration if it meets the requirements defined the Presentation Definition of the Discovery Service. - Registration of all DIDs of a subject will be attempted immediately, and they will be automatically refreshed. + Registration of all DIDs of a subject will be attempted immediately. + If at least one DID is registered on the Discovery Server, the operation is considered a success and will be periodically refreshed for the entire subject. Applications only need to call this API once for every service/subject combination, until the registration is explicitly deleted through this API. - If initial registration fails, this API returns the error indicating what failed and periodically retry registration. Applications can force a retry by calling this API again. error returns: - * 400 - incorrect input: invalid/unknown service or subject. - * 412 - precondition failed: subject doesn't have the required credentials. + * 404 - unknown service or subject + * 412 - precondition failed: subject doesn't have the required credentials operationId: activateServiceForSubject tags: - discovery requestBody: - required: true content: application/json: schema: @@ -172,21 +176,16 @@ paths: responses: "200": description: Activation was successful. - - "400": - $ref: "../common/error_response.yaml" - "412": - $ref: "../common/error_response.yaml" default: $ref: "../common/error_response.yaml" delete: - summary: Client API to deactivate the given subject from the Discovery Service. + summary: Remove a subject from the Discovery Service. description: | An API provided by the discovery client that will cancel the periodic registration of a subject on the specified Discovery Service. It will also try to delete all the existing registrations on the Discovery Service, if any. error returns: - * 400 - incorrect input: invalid/unknown service or subject. + * 404 - unknown service or subject operationId: deactivateServiceForSubject tags: - discovery @@ -209,8 +208,6 @@ paths: 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: @@ -269,6 +266,11 @@ components: id: type: string description: The ID of the Discovery Service. + did_methods: + type: array + items: + type: string + description: List of DID Methods supported by the Discovery Service. Empty/missing means no restrictions. endpoint: type: string description: The endpoint of the Discovery Service. diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index f6e891d382..64dce67d0a 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -530,17 +530,17 @@ paths: description: The credential will not be altered in any way, so no need to return it. default: $ref: '../common/error_response.yaml' - /internal/vcr/v2/holder/{did}/vc/{id}: + /internal/vcr/v2/holder/{subjectID}/vc/{id}: parameters: - - name: did + - name: subjectID in: path - description: URL encoded DID. + description: Subject ID of the wallet owner at this node. required: true content: plain/text: schema: type: string - example: did:web:example.com + example: 90BC1AE9-752B-432F-ADC3-DD9F9C61843C - name: id in: path description: URL encoded VC ID. @@ -552,15 +552,15 @@ paths: description: | Remove a VerifiableCredential from the holders wallet. After removal the holder can't present the credential any more. It does not revoke the credential or inform the credential issuer that the wallet removed the wallet. - + error returns: * 400 - Invalid credential - * 404 - Credential not found + * 404 - Credential or subject not found * 500 - An error occurred while processing the request operationId: removeCredentialFromWallet tags: - - credential + - credential responses: "204": description: Credential has been removed from the wallet. diff --git a/docs/_static/vdr/v2.yaml b/docs/_static/vdr/v2.yaml index f5ef50d753..4ef22e9f20 100644 --- a/docs/_static/vdr/v2.yaml +++ b/docs/_static/vdr/v2.yaml @@ -131,6 +131,7 @@ paths: error returns: * 400 - the subject param was malformed * 404 - Corresponding subject could not be found + * 409 - The subject is already deactivated * 500 - An error occurred while processing the request operationId: "deactivate" tags: @@ -297,7 +298,8 @@ paths: summary: Delete a specific service from the subject description: | Removes the service from all DID Documents in the subject. Matching is done on the fragment of the id. - No cascading will happen for references to the service. + No cascading will happen for references to the service. + Make sure to only URL encode the pound (#) as %23 in the serviceId. Do not encode the colons (:). error returns: * 400 - Returned in case of malformed subject or service ID @@ -315,10 +317,12 @@ paths: summary: Updates a service for the subject description: | It replaces the given service in all DID Documents of the subject by deleting the current service and adding the provided service with a newly generated ID. + Make sure to only URL encode the pound (#) as %23 in the serviceId. Do not encode the colons (:). error returns: * 400 - Returned in case of malformed subject or service ID * 404 - Corresponding subject or service could not be found + * 409 - Duplicate service type * 500 - An error occurred while processing the request tags: - Subject @@ -478,7 +482,9 @@ components: properties: subject: type: string - description: controls the DID subject to which all created DIDs are bound. If not given, a uuid is generated and returned. + description: | + controls the DID subject to which all created DIDs are bound. If not given, a uuid is generated and returned. + The subject must follow the pattern [a-zA-Z0-9._-]+ keys: $ref: '#/components/schemas/KeyCreationOptions' DIDDocument: diff --git a/docs/index.rst b/docs/index.rst index 9ce5996a7b..7e8f864688 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,6 +4,12 @@ Nuts documentation ################## +.. note:: + + This version of the documentation is for Nuts v6.0.0. + It no longer describes the setup of the Nuts network required for did:nuts. + If you're looking for information on setting up a Nuts network, please refer to the `previous version `_. + .. toctree:: :maxdepth: 1 :caption: Integrating: @@ -23,6 +29,7 @@ Nuts documentation pages/deployment/domain.rst pages/deployment/configuration.rst + pages/deployment/migration.rst pages/deployment/recommended-deployment.rst pages/deployment/docker.rst pages/deployment/storage.rst diff --git a/docs/pages/deployment/configuration.rst b/docs/pages/deployment/configuration.rst index 88f6919944..4385fcb20a 100644 --- a/docs/pages/deployment/configuration.rst +++ b/docs/pages/deployment/configuration.rst @@ -77,8 +77,8 @@ Several of the server options above allow the node to be configured in a way tha The node can be configured to run in strict mode (default) to prevent any insecure configurations. Below is a summary of the impact ``strictmode=true`` has on the node and its configuration. -Save storage of any private key material requires some serious consideration. -For this reason the ``crypto.storage`` backend must explicitly be set. +Save storage of any private key material and data requires some serious consideration. +For this reason the ``crypto.storage`` backend and the ``storage.sql.connection`` connection string must explicitly be set. Private transactions can only be exchanged over authenticated nodes. Therefore is requires TLS to be configured through ``tls.{certfile,certkeyfile,truststore}``. diff --git a/docs/pages/deployment/discovery.rst b/docs/pages/deployment/discovery.rst index d9365eddde..b6d1104b5b 100644 --- a/docs/pages/deployment/discovery.rst +++ b/docs/pages/deployment/discovery.rst @@ -60,6 +60,7 @@ Optionally, a POST body can be provided with registration parameters, e.g.: This can be used to provide additional information. All registration parameters are returned by the search API. The ``authServerURL`` is added automatically by the Nuts node. It's constructed as ``https:///oauth2/``. +Registration parameters can only be used if the specific parameters and/or ``DiscoveryRegistrationCredential`` are required by the Presentation Definition. Once registered, future refreshes will be done automatically by the Nuts node. These refreshes could fail because of various reasons. You can check the status of the refreshes by querying the service, e.g.: @@ -156,6 +157,18 @@ Service definitions } ] } + }, { + "id": "DiscoveryRegistrationCredential", + "constraints": { + "fields": [ + { + "id": "auth_server_url", + "path": [ + "$.credentialSubject.authServerURL" + ] + } + ] + } } ] } diff --git a/docs/pages/deployment/migration.rst b/docs/pages/deployment/migration.rst new file mode 100644 index 0000000000..6734b695d6 --- /dev/null +++ b/docs/pages/deployment/migration.rst @@ -0,0 +1,38 @@ +.. _nuts-node-migration: + +Migrating from v5 to v6 +************************ + +Nuts node v6 runs several migrations on startup for DID documents that are managed by the node, namely: + +1. Remove controllers and add self-control to ``did:nuts`` documents, +2. Import ``did:nuts`` documents into the new SQL database under a ``subject`` with the same name, and +3. Add a ``did:web`` document to the same ``subject``. + +**Migration: convert did:nuts to self-control** +Requires ``didmethods`` to contain ``nuts``. + +Previously, DID documents could either by under self-control or under control of another DID as was recommended for vendor and care organisation, respectively. +In the new situation a user manages ``subject``s, and the node manages all DIDs under the ``subject``. +To reduce complexity and allow future adoption of other did methods, all documents will be under self-control from v6. + +**Migration: convert did:nuts to subject** +Requires ``didmethods`` to contain ``nuts``. + +All owned ``did:nuts`` DID documents will be migrated to the new SQL storage. +This migration includes all historic document updates as published upto a potential deactivation of the document. +For DIDs with a document conflict this is different than the resolved version of the document, which contains a merge of all conflicting document updates. +To prevent the state of the resolver and the SQL storage to be in conflict, all DID document conflicts must be resolved before upgrading to v6. +See ``/status/diagnostics`` if you own any DIDs with a document conflict. If so, use ``/internal/vdr/v1/did/conflicted`` to find the DIDs with a conflict. + +.. note:: + + The document migration will run on every restart of the node, meaning that any updates made using the VDR V1 API will be migrated on the next restart. + However, any changes made via the V1 API wil NOT propagate to other DID documents under the same ``subject``, so you MUST set ``didmethods = ["nuts"]`` to use the V1 API. + +**Migration: add did:web to subjects** +Requires ``didmethods`` to contain ``web`` and ``nuts`` (default). + +This migration adds a new ``did:web`` DID Document to owned subjects that do not already have one. +A new verification method is created for the document and added to all verification relationships except KeyAgreement. +This means did:web cannot be used for encryption (yet). diff --git a/docs/pages/deployment/pex.rst b/docs/pages/deployment/pex.rst index ba2b2314a3..4efa271d55 100644 --- a/docs/pages/deployment/pex.rst +++ b/docs/pages/deployment/pex.rst @@ -95,3 +95,32 @@ Writer of policies should take into consideration: - fields that are intended to be used for logging or authorization decisions should have a distinct identifier. - claims ideally map a registered claim name (e.g. `IANA JWT claims `_) - overwriting properties already defined in the token introspection endpoint response is forbidden. These are: ``iss``, ``sub``, ``exp``, ``iat``, ``active``, ``client_id``, ``scope``. + +Extracting substrings with regular expressions +============================================== +If you want introspection to return part of a string, you can use the ``pattern`` regular expression filter in the field definition with a capture group. +Token introspection will return the value of the capture group in the regular expression, instead of the whole field value. +For instance, if you want to extract the level from the string ``"Admin level 4"`` from the following credential: + +.. code-block:: json + + { + "credentialSubject": { + "role": "Admin level 4" + } + } + +You can define the following field in the input descriptor constraint, to have the level returned in the introspection response as ``admin_level``: + +.. code-block:: json + + { + "id": "admin_level", + "path": ["$.credentialSubject.role"], + "filter": { + "type": "string" + "pattern": "Admin level ([0-9])" + } + } + +Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. \ No newline at end of file diff --git a/docs/pages/deployment/security-considerations.rst b/docs/pages/deployment/security-considerations.rst index 38012b22fe..de201d0709 100644 --- a/docs/pages/deployment/security-considerations.rst +++ b/docs/pages/deployment/security-considerations.rst @@ -19,6 +19,13 @@ D(D)oS Protection ***************** Consider implementing (D)DoS protection on the application layer for all public endpoints. +Monitor and log the following metrics: + +- Number of requests per second +- Number of requests from a single IP address +- Amount of non-20x responses + +Any outliers should be investigated. Maximum client body size for public-facing POST APIs **************************************************** @@ -32,6 +39,7 @@ The following public APIs accept POST requests: - ``/oauth2/{subjectID}/response`` To prevent malicious uploads, you MUST limit the size of the requests. +As a safeguard, the Nuts node will also limit the size of request bodies. For example, Nginx has a configuration directive to limit the size of the request body: diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 96c3669cf8..e4c2c47f3f 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -8,6 +8,7 @@ configfile ./config/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. + didmethods [web,nuts] Comma-separated list of enabled DID methods (without did: prefix). It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used. 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. @@ -31,6 +32,7 @@ discovery.definitions.directory ./config/discovery 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.ids [] IDs of the Discovery Service for which to act as server. If an ID does not map to a loaded service definition, the node will fail to start. **HTTP** + http.clientipheader X-Forwarded-For Case-sensitive HTTP Header that contains the client IP used for audit logs. For the X-Forwarded-For header only link-local, loopback, and private IPs are excluded. Switch to X-Real-IP or a custom header if you see your own proxy/infra in the logs. http.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). When debug vebosity is set the authorization headers are also logged when the request is fully logged. http.cache.maxbytes 10485760 HTTP client maximum size of the response cache in bytes. If 0, the HTTP client does not cache responses. http.internal.address 127.0.0.1:8081 Address and port the server will be listening to for internal-facing endpoints. @@ -51,8 +53,6 @@ storage.session.redis.username Redis session database username. If set, it overrides the username in the connection URL. storage.session.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis session 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'). - **VDR** - vdr.didmethods [web,nuts] Comma-separated list of enabled DID methods (without did: prefix). It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ diff --git a/docs/pages/deployment/storage.rst b/docs/pages/deployment/storage.rst index 10f0181e43..413a48f592 100644 --- a/docs/pages/deployment/storage.rst +++ b/docs/pages/deployment/storage.rst @@ -21,9 +21,9 @@ Also remember to test your backup and restore procedure. SQL database ************ -By default, storage SQLite will be used in a file called ``sqlite.db`` in the configured data directory. -This can be overridden by configuring a connection string in ``storage.sql.connection``. -Other supported SQL databases are Postgres, MySQL, Microsoft SQL Server and Microsoft Azure SQL Server. +Currently supported SQL databases are Postgres, MySQL, Microsoft SQL Server, Microsoft Azure SQL Server, and SQLite. +The database of your preference can be set by configuring a connection string in ``storage.sql.connection``. +Only in non-strictmode, if no connection string is set this will default to SQLite in a file called ``sqlite.db`` in the configured data directory. Connection strings must be in the following format: @@ -39,6 +39,11 @@ Refer to the documentation of the driver for the database you are using for the - Azure SQL Server: `github.com/microsoft/go-mssqldb `_ (e.g. ``azuresql://server=awesome-server;port=1433;database=awesome-db;fedauth=ActiveDirectoryDefault;``) - SQLite (e.g. ``sqlite:file:/some/path/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)``) +.. warning:: + + Usage of SQLite is not recommended for production environments. + Connections to a SQLite DB are restricted to 1, which will lead to severe performance reduction. + Private Keys ************ diff --git a/docs/pages/integrating/version-incompatibilities.rst b/docs/pages/integrating/version-incompatibilities.rst index 189333b6d1..23e167f96a 100644 --- a/docs/pages/integrating/version-incompatibilities.rst +++ b/docs/pages/integrating/version-incompatibilities.rst @@ -11,9 +11,23 @@ There's also a config parameter that allows you to limit the DID methods in use. Not all combinations of API usage and DID methods are supported. There are basically two options. -1. Keep using the VDR V1 API (for now) and set ``vdr.did_methods`` to ``["nuts"]``. -2. Use the VDR V2 API and set ``vdr.did_methods`` to include other methods or leave blank for default setting. +1. Keep using the VDR V1 API (for now) and set ``didmethods`` to ``["nuts"]``. +2. Use the VDR V2 API and set ``didmethods`` to include other methods or leave blank for default setting. Do not use the VDR V1 and VDR V2 API at the same time. This will lead to unexpected behavior. Once you use the VDR V2 API, you cannot go back to the VDR V1 API. The VDR V1 API has also been marked as deprecated. +Publishing Services for use-cases +********************************* + +V5 use-cases define service endpoints or a collection of endpoints that should be registered in the Services on DID Documents. +The concrete endpoints are usually on the DID Document of the vendor, and then referenced by all DID Documents managed by that vendor. +And ``did:nuts`` for example, requires the registration of a ``NutsComm`` endpoint to authenticate the connection. +Use-cases built on V5 should keep using the DIDMan API to manage and resolve Services on DID Documents. +Any Service change made using the DIDMan API will only update ``did:nuts`` DID Documents. + +For use-cases built on V6, any endpoint needed for the use-case should be listed in the registration on the Discovery Service for that use-case, see :ref:`discovery` Registration. +This means that ``did:web`` DID Documents (or non-did:nuts if we look further ahead) will contain very few Services, if any. +If there is a need to add a Service for V6 use-cases, they should be added using the VDR v2 API, which will then add the Service to _all_ DIDs that are part of the Subject. +Note that resolving Services using the VDR v2 API will return the Service from the document as is. +So, it resolves Services without following any references in the Service to a concrete endpoint as is done by DIDMan. \ No newline at end of file diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index af00dec687..92360079f0 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -3,11 +3,12 @@ Release notes ############# ******************* -Peanut (6.0.0) +Peanut (v6.0.0) ******************* -**Release date:** TBD -**Full Changelog**: https://github.com/nuts-foundation/nuts-node/compare/v5.0.0...v6.0.0 +Release date: 2024-10-25 + +**Full Changelog**: https://github.com/nuts-foundation/nuts-node/compare/v5.4.0...v6.0.0 ================ Breaking changes @@ -17,7 +18,9 @@ Breaking changes When migrating from v5, change the owner of the data directory on the host to that of the container's user. (``chown -R 18081:18081 /path/to/host/data-dir``) - Docker image tags have been changed: previously version tags had were prefixed with ``v`` (e.g., ``v5.0.0``), this prefix has been dropped to better adhere to industry standards. - The VDR v1 ``createDID`` (``POST /internal/vdr/v1/did``) no longer supports the ``controller`` and ``selfControl`` fields. All did:nuts documents are now self controlled. All existing documents will be migrated to self controlled at startup. +- Managed ``did:nuts`` DIDs are migrated to the new SQL storage. Unresolved DID document conflicts may contain an incorrect state after migrating to v6. See ``/status/diagnostics`` if you own any DIDs with a document conflict; use ``/internal/vdr/v1/did/conflicted`` to find the specific DIDs. - Removed legacy API authentication tokens. +- See caveats in :ref:`version-incompatibilities`. ============ New Features @@ -51,7 +54,7 @@ Changes - Removed support for the UZI authentication means. - Documentation of ``did:nuts``-related features have been removed (refer to v5 documentation). - Documentation of specific use cases (e.g. health care in general or eOverdracht) has been moved to the `Nuts wiki `_. -- Node can now be run without configuring TLS when the gRPC network isn't used (no bootstrap node configured and no network state), to cater use cases that don't use ``did:nuts``. +- Node can now be run without configuring TLS when the gRPC network isn't used (``didmethods`` does not contain ``nuts``), to cater use cases that don't use ``did:nuts``. - Crypto backends store keys under a key name and are linked to the kid via the ``key_reference`` SQL table. The following features have also been changed: @@ -61,8 +64,9 @@ DID management You no longer manage changes to DIDs but to Subjects. Each subject has multiple DIDs, one for each enabled DID method. You're free to choose an ID for a Subject. This feature enables forwards compatibility with new DID methods. -DID methods can be enabled and disabled via the ``vdr.didmethods`` config parameter. (Default: ``['web','nuts']``). -Existing ``did:nuts`` documents will be migrated to self-controlled at startup and the DID will be added as SubjectID. +DID methods can be enabled and disabled via the ``didmethods`` config parameter. (Default: ``['web','nuts']``). +Existing ``did:nuts`` documents will be migrated to self-controlled at startup and the DID will be added as SubjectID together with a new ``did:web`` DID. +See :ref:`nuts-node-migrations` for more information. HTTP interface ============== @@ -94,7 +98,10 @@ The following features have been deprecated: Starting v6, the preferred way to support other key storage backends is to directly implement it in the Nuts node itself. This also reduces the complexity of a Nuts node deployment (one service less to configure and deploy). Users are recommended to switch to the built-in client of their key storage backend. -- VDR v1 API. +- Auth v1 API, replaced by Auth v2 +- DIDMan v1 API, to be removed +- Network v1 API, to be removed +- VDR v1 API, replaced by VDR v2 ************************ Hazelnut update (v5.4.11) diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 3d8ca34877..d264b86e77 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -1,6 +1,6 @@ // Package iam provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package iam import ( @@ -85,7 +85,7 @@ type ExtendedTokenIntrospectionResponse struct { // Aud RFC7662 - Service-specific string identifier or list of string identifiers representing the intended audience for this token, as defined in JWT [RFC7519]. Aud *string `json:"aud,omitempty"` - // ClientId The client (DID) the access token was issued to + // ClientId The client identity the access token was issued to. Since the Verifiable Presentation is used to grant access, the client_id reflects the client_id in the access token request. ClientId *string `json:"client_id,omitempty"` // Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. @@ -97,7 +97,7 @@ type ExtendedTokenIntrospectionResponse struct { // Iat Issuance time in seconds since UNIX epoch Iat *int `json:"iat,omitempty"` - // Iss Contains the DID of the authorizer. Should be equal to 'sub' + // Iss Issuer URL of the authorizer. Iss *string `json:"iss,omitempty"` // PresentationDefinitions Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token diff --git a/e2e-tests/browser/openid4vp_employeecredential/config/nuts.yaml b/e2e-tests/browser/openid4vp_employeecredential/config/nuts.yaml index eef6fe6a4a..3f31e3dd60 100644 --- a/e2e-tests/browser/openid4vp_employeecredential/config/nuts.yaml +++ b/e2e-tests/browser/openid4vp_employeecredential/config/nuts.yaml @@ -1,4 +1,5 @@ url: https://nodeA +didmethods: ["web"] strictmode: false verbosity: debug http: @@ -11,7 +12,4 @@ auth: authorizationendpoint: enabled: true policy: - directory: /opt/nuts/policy -vdr: - didmethods: - - web \ No newline at end of file + directory: /opt/nuts/policy \ No newline at end of file diff --git a/e2e-tests/browser/rfc019_selfsigned/config/node/nuts.yaml b/e2e-tests/browser/rfc019_selfsigned/config/node/nuts.yaml index 2e5d526656..cb813d7772 100644 --- a/e2e-tests/browser/rfc019_selfsigned/config/node/nuts.yaml +++ b/e2e-tests/browser/rfc019_selfsigned/config/node/nuts.yaml @@ -6,4 +6,4 @@ http: address: :8081 auth: contractvalidators: - - selfsigned + - employeeid diff --git a/e2e-tests/browser/rfc019_selfsigned/run-test.sh b/e2e-tests/browser/rfc019_selfsigned/run-test.sh index b054cc29ba..8c8c3d86b9 100755 --- a/e2e-tests/browser/rfc019_selfsigned/run-test.sh +++ b/e2e-tests/browser/rfc019_selfsigned/run-test.sh @@ -4,12 +4,11 @@ source ../../util.sh set -e # make script fail if any of the tests returns a non-zero exit code # Shut down existing containers -docker compose stop +docker compose down # Start new stack docker compose up --wait - go test -v --tags=e2e_tests . docker compose stop \ No newline at end of file diff --git a/e2e-tests/discovery/definitions/definition.json b/e2e-tests/discovery/definitions/definition.json index 81a01bd4fe..d00b41dbf0 100644 --- a/e2e-tests/discovery/definitions/definition.json +++ b/e2e-tests/discovery/definitions/definition.json @@ -46,6 +46,18 @@ } ] } + },{ + "id": "DiscoveryRegistrationCredential", + "constraints": { + "fields": [ + { + "id": "auth_server_url", + "path": [ + "$.credentialSubject.authServerURL" + ] + } + ] + } } ] } diff --git a/e2e-tests/discovery/node-A/nuts.yaml b/e2e-tests/discovery/node-A/nuts.yaml index af2ad4acc3..1bb1772d8b 100644 --- a/e2e-tests/discovery/node-A/nuts.yaml +++ b/e2e-tests/discovery/node-A/nuts.yaml @@ -1,4 +1,5 @@ url: http://nodeA +didmethods: ["web"] verbosity: debug strictmode: false internalratelimiter: false @@ -20,6 +21,3 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem -vdr: - didmethods: - - web diff --git a/e2e-tests/discovery/node-B/nuts.yaml b/e2e-tests/discovery/node-B/nuts.yaml index b48a05b064..fb654b3a1d 100644 --- a/e2e-tests/discovery/node-B/nuts.yaml +++ b/e2e-tests/discovery/node-B/nuts.yaml @@ -1,4 +1,5 @@ url: https://nodeB +didmethods: ["web"] verbosity: debug strictmode: false internalratelimiter: false @@ -19,7 +20,4 @@ auth: tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem - certkeyfile: /opt/nuts/certificate-and-key.pem -vdr: - didmethods: - - web \ No newline at end of file + certkeyfile: /opt/nuts/certificate-and-key.pem \ No newline at end of file diff --git a/e2e-tests/discovery/run-test.sh b/e2e-tests/discovery/run-test.sh index dd88659cf3..224fee67cb 100755 --- a/e2e-tests/discovery/run-test.sh +++ b/e2e-tests/discovery/run-test.sh @@ -48,7 +48,13 @@ fi echo "---------------------------------------" echo "Registering care organization on Discovery Service..." echo "---------------------------------------" -curl --insecure -s -X POST http://localhost:28081/internal/discovery/v1/dev:eOverdracht2023/${SUBJECT} +RESPONSE=$(curl --insecure -s -X POST http://localhost:28081/internal/discovery/v1/dev:eOverdracht2023/${SUBJECT}) +if [ -z "${RESPONSE}" ]; then + echo "Registered for service" +else + echo "FAILED: Could not register for Discovery Service" 1>&2 + exitWithDockerLogs 1 +fi # Registration refresh interval is 500ms, wait some to make sure the registration is refreshed sleep 2 @@ -56,7 +62,7 @@ sleep 2 echo "---------------------------------------" echo "Searching for care organization registration on Discovery Server..." echo "---------------------------------------" -RESPONSE=$(curl -s --insecure "http://localhost:18081/internal/discovery/v1/dev:eOverdracht2023?credentialSubject.organization.name=Care*") +RESPONSE=$(curl -s --insecure "http://localhost:18081/internal/discovery/v1/dev:eOverdracht2023?credentialSubject.organization.name=Care*&credentialSubject.organization.city=*") NUM_ITEMS=$(echo $RESPONSE | jq length) if [ $NUM_ITEMS -eq 1 ]; then echo "Registration found" @@ -68,8 +74,6 @@ fi echo "---------------------------------------" echo "Searching for care organization registration on Discovery Client..." echo "---------------------------------------" -# Service refresh interval is 500ms, wait some to make sure the presentations are loaded -sleep 2 RESPONSE=$(curl -s --insecure "http://localhost:28081/internal/discovery/v1/dev:eOverdracht2023?credentialSubject.organization.name=Care*") NUM_ITEMS=$(echo $RESPONSE | jq length) if [ $NUM_ITEMS -eq 1 ]; then @@ -87,6 +91,44 @@ else exitWithDockerLogs 1 fi +echo "---------------------------------------" +echo "Retract Discovery Service registration..." +echo "---------------------------------------" +RESPONSE=$(curl --insecure -s -X DELETE http://localhost:28081/internal/discovery/v1/dev:eOverdracht2023/${SUBJECT}) +if [ -z "${RESPONSE}" ]; then + echo "Registration revoked" +else + echo "FAILED: Registration not (immediately) revoked" 1>&2 + exitWithDockerLogs 1 +fi + +# Registration refresh interval is 500ms, wait some to make sure the registration is refreshed +sleep 2 + +echo "---------------------------------------" +echo "Searching for care organization registration on Discovery Server..." +echo "---------------------------------------" +RESPONSE=$(curl -s --insecure "http://localhost:18081/internal/discovery/v1/dev:eOverdracht2023?credentialSubject.organization.name=Care*") +NUM_ITEMS=$(echo $RESPONSE | jq length) +if [ $NUM_ITEMS -eq 0 ]; then + echo "Registration not found" +else + echo "FAILED: Found registration" 1>&2 + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Searching for care organization registration on Discovery Client..." +echo "---------------------------------------" +RESPONSE=$(curl -s --insecure "http://localhost:28081/internal/discovery/v1/dev:eOverdracht2023?credentialSubject.organization.name=Care*") +NUM_ITEMS=$(echo $RESPONSE | jq length) +if [ $NUM_ITEMS -eq 0 ]; then + echo "Registration not found" +else + echo "FAILED: Found registration" 1>&2 + exitWithDockerLogs 1 +fi + echo "------------------------------------" echo "Stopping Docker containers..." echo "------------------------------------" diff --git a/e2e-tests/migration/docker-compose-post-migration.yml b/e2e-tests/migration/docker-compose-post-migration.yml new file mode 100644 index 0000000000..37d8d62015 --- /dev/null +++ b/e2e-tests/migration/docker-compose-post-migration.yml @@ -0,0 +1,32 @@ +services: + nodeA: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" + container_name: nodeA + user: &usr "$USER:$USER" + ports: + - "18081:8081" + environment: + NUTS_URL: "http://nodeA:8080" + volumes: + - "./nuts-v6.yaml:/nuts/config/nuts.yaml" + - "./nodeA/data:/nuts/data" + - "../tls-certs/nodeA-certificate.pem:/nuts/config/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/nuts/config/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: nutsfoundation/nuts-node:v5.4.11 # must be v5.4.11+ for bugfixes. sync with docker-compose-post-migration.yml + container_name: nodeB + user: *usr + ports: + - "28081:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + NUTS_NETWORK_BOOTSTRAPNODES: "nodeA:5555" + volumes: + - "./nuts-v5.yaml:/opt/nuts/nuts.yaml" + - "./nodeB/data:/opt/nuts/data" + - "../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file diff --git a/e2e-tests/migration/docker-compose-pre-migration.yml b/e2e-tests/migration/docker-compose-pre-migration.yml new file mode 100644 index 0000000000..7c5c6f4fca --- /dev/null +++ b/e2e-tests/migration/docker-compose-pre-migration.yml @@ -0,0 +1,32 @@ +services: + nodeA: + image: &im nutsfoundation/nuts-node:v5.4.11 # must be v5.4.11+ for bugfixes. sync with docker-compose-post-migration.yml + container_name: nodeA + user: &usr "$USER:$USER" + ports: # use v6 ports to minimize changes + - "18081:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + volumes: + - "./nuts-v5.yaml:/opt/nuts/nuts.yaml" + - "./nodeA/data:/opt/nuts/data" + - "../tls-certs/nodeA-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: *im + container_name: nodeB + user: *usr + ports: + - "28081:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + NUTS_NETWORK_BOOTSTRAPNODES: "nodeA:5555" + volumes: + - "./nuts-v5.yaml:/opt/nuts/nuts.yaml" + - "./nodeB/data:/opt/nuts/data" + - "../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file diff --git a/e2e-tests/migration/main_test.go b/e2e-tests/migration/main_test.go new file mode 100644 index 0000000000..2875d63ddb --- /dev/null +++ b/e2e-tests/migration/main_test.go @@ -0,0 +1,152 @@ +//go:build e2e_tests + +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package migration + +import ( + "encoding/json" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "strings" + "testing" +) + +type manager struct { + DID *didsubject.SqlDIDManager + DOC *didsubject.SqlDIDDocumentManager +} + +func Test_Migrations(t *testing.T) { + db := storage.NewTestStorageEngineInDir(t, "./nodeA/data").GetSQLDatabase() + man := &manager{ + DID: didsubject.NewDIDManager(db), + DOC: didsubject.NewDIDDocumentManager(db), + } + + DIDs, err := man.DID.All() + require.NoError(t, err) + require.Len(t, DIDs, 7) // 4 did:nuts, 3 did:web + + t.Run("vendor", func(t *testing.T) { + // versions for did:nuts: + // - LC0: init -> no controller because vendor + // - LC4: add service1 + // - LC4: add service2, conflicts with above + // - LC8: add verification method, solves conflict + // no updates during migration + // + // total 4 versions in SQL; latest has 2 services and 2 VMs + id := did.MustParseDID(os.Getenv("VENDOR_DID")) + doc, err := man.DOC.Latest(id, nil) + require.NoError(t, err) + + assert.Equal(t, 3, doc.Version) + assert.Len(t, doc.Services, 2) + assert.Len(t, doc.VerificationMethods, 2) + + // migration: add did:web + hasDIDWeb(t, man, doc) + }) + t.Run("org1", func(t *testing.T) { + // versions for did:nuts: + // - LC1: init -> has controller + // - LC5: add service2 + // - LC6: add service1, conflicts with above + // migration removes controller (solves document conflict) + // + // total 4 versions in SQL; latest one has no controller, 2 services, and 1 VM + id := did.MustParseDID(os.Getenv("ORG1_DID")) + doc, err := man.DOC.Latest(id, nil) + require.NoError(t, err) + + assert.Equal(t, 3, doc.Version) + assert.Len(t, doc.Services, 2) + assert.Len(t, doc.VerificationMethods, 1) + didDoc := new(did.Document) + require.NoError(t, json.Unmarshal([]byte(doc.Raw), didDoc)) + assert.Empty(t, didDoc.Controller) + + // migration: add did:web + hasDIDWeb(t, man, doc) + }) + t.Run("org2", func(t *testing.T) { + // versions for did:nuts: + // - LC2: init -> has controller + // - LC5: deactivate + // - LC6: service2, conflicts with above + // deactivated, so no updates during migration; + // + // total 2 versions in SQL, migration stopped at LC5; no controller, 0 service, 0 VM + id := did.MustParseDID(os.Getenv("ORG2_DID")) + doc, err := man.DOC.Latest(id, nil) + require.NoError(t, err) + + assert.Equal(t, 1, doc.Version) + assert.Len(t, doc.Services, 0) + assert.Len(t, doc.VerificationMethods, 0) + + // deactivated; has no did:web + dids, err := man.DID.FindBySubject(doc.DID.Subject) // migrated documents have subject == did:nuts:... + require.NoError(t, err) + assert.Len(t, dids, 1) + }) + t.Run("org3", func(t *testing.T) { + // versions for did:nuts: + // - LC3: init -> has controller + // - LC7: add service1 + // - LC7: add verification method, conflicts with above + // - LC9: add service2, solves conflict + // migration removes controller + // + // total 5 versions in SQL; no controller, 2 services, 2 VMs + id := did.MustParseDID(os.Getenv("ORG3_DID")) + doc, err := man.DOC.Latest(id, nil) + require.NoError(t, err) + + assert.Equal(t, 4, doc.Version) + assert.Len(t, doc.Services, 2) + assert.Len(t, doc.VerificationMethods, 2) + didDoc := new(did.Document) + require.NoError(t, json.Unmarshal([]byte(doc.Raw), didDoc)) + assert.Empty(t, didDoc.Controller) + + // migration: add did:web + hasDIDWeb(t, man, doc) + }) +} + +func hasDIDWeb(t *testing.T, man *manager, nutsDoc *orm.DidDocument) { + dids, err := man.DID.FindBySubject(nutsDoc.DID.Subject) // migrated documents have subject == did:nuts:... + require.NoError(t, err) + assert.Len(t, dids, 2) + var webDoc *orm.DidDocument + for _, id := range dids { + if strings.HasPrefix(id.ID, "did:web:") { + webDoc, err = man.DOC.Latest(did.MustParseDID(id.ID), nil) + require.NoError(t, err) + } + } + assert.Equal(t, 0, webDoc.Version) +} diff --git a/e2e-tests/migration/nuts-v5.yaml b/e2e-tests/migration/nuts-v5.yaml new file mode 100644 index 0000000000..7cb98a1639 --- /dev/null +++ b/e2e-tests/migration/nuts-v5.yaml @@ -0,0 +1,12 @@ +datadir: /opt/nuts/data +verbosity: debug +strictmode: false +tls: + truststorefile: "/opt/nuts/truststore.pem" + certfile: "/opt/nuts/certificate-and-key.pem" + certkeyfile: "/opt/nuts/certificate-and-key.pem" +auth: + contractvalidators: + - dummy +goldenhammer: + enabled: false \ No newline at end of file diff --git a/e2e-tests/migration/nuts-v6.yaml b/e2e-tests/migration/nuts-v6.yaml new file mode 100644 index 0000000000..cf8b297bd1 --- /dev/null +++ b/e2e-tests/migration/nuts-v6.yaml @@ -0,0 +1,15 @@ +verbosity: debug +strictmode: false +#url: http://nodeA:1323 # set in as env variable +tls: + truststorefile: "/nuts/config/truststore.pem" + certfile: "/nuts/config/certificate-and-key.pem" + certkeyfile: "/nuts/config/certificate-and-key.pem" +auth: + contractvalidators: + - dummy +goldenhammer: + enabled: false +http: + internal: + address: :8081 diff --git a/e2e-tests/migration/run-test.sh b/e2e-tests/migration/run-test.sh new file mode 100755 index 0000000000..641699ce42 --- /dev/null +++ b/e2e-tests/migration/run-test.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +source ../util.sh +USER=$UID + +######################################################### +# THE ORDER OF TRANSACTIONS IS SIGNIFICANT, DONT CHANGE # +######################################################### + +# createOrg creates a DID under the control of another DID +# Args: controller DID +# Returns: the created DID +function createOrg() { + printf '{ + "selfControl": false, + "controllers": ["%s"], + "assertionMethod": true, + "capabilityInvocation": false + }' "$1" | \ + curl -sS -X POST "http://localhost:18081/internal/vdr/v1/did" -H "Content-Type: application/json" --data-binary @- | jq -r ".id" +} + +# addServiceV1 add a service to a DID document using the vdr/v1 API +# Args: service host, service type, DID to add the service to +# Returns: null +function addServiceV1() { + printf '{ + "type": "%s", + "endpoint": "%s/%s" + }' "$2" "$1" "$2" | \ + curl -sS -X POST "http://localhost:18081/internal/didman/v1/did/$3/endpoint" -H "Content-Type: application/json" --data-binary @- > /dev/null +} + +# addVerificationMethodV1 add a verification method to a DID document using the vdr/v1 API +# Args: DID to add the verification method to +# Returns: null +function addVerificationMethodV1() { + curl -sS -X POST "http://localhost:18081/internal/vdr/v1/did/$1/verificationmethod" > /dev/null +} + +# deactivateDIDV1 deactivates a DID document using the vdr/v1 API +# Args: DID to deactivate +# Returns: null +function deactivateDIDV1() { + curl -sS -X DELETE "http://localhost:18081/internal/vdr/v1/did/$1" > /dev/null +} + +echo "------------------------------------" +echo "Cleaning up running Docker containers and volumes, and key material..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml down +docker compose -f docker-compose-pre-migration.yml rm -f -v +rm -rf ./node*/ +mkdir -p ./nodeA/{data,backup} ./nodeB/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 -f docker-compose-pre-migration.yml up --wait nodeA nodeB + +echo "------------------------------------" +echo "Registering DIDs..." +echo "------------------------------------" +# Register Vendor +VENDOR_DID=$(curl -X POST -sS http://localhost:18081/internal/vdr/v1/did | jq -r .id) +echo Vendor DID: "$VENDOR_DID" +# Register org1 +ORG1_DID=$(createOrg "$VENDOR_DID") +echo Org1 DID: "$ORG1_DID" +# Register org2 +ORG2_DID=$(createOrg "$VENDOR_DID") +echo Org2 DID: "$ORG2_DID" +# Register org3 +ORG3_DID=$(createOrg "$VENDOR_DID") +echo Org3 DID: "$ORG3_DID" + +# Wait for NodeB to contain 4 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 4 10 + +echo "------------------------------------" +echo "Making backup nodeA..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml stop nodeA +cp -R ./nodeA/data/* ./nodeA/backup/ +docker compose -f docker-compose-pre-migration.yml up --wait nodeA + +echo "------------------------------------" +echo "Adding and syncing left branch..." +echo "------------------------------------" +addServiceV1 "http://vendor" "service1" "$VENDOR_DID" +deactivateDIDV1 "$ORG2_DID" +addServiceV1 "http://org1" "service1" "$ORG1_DID" +addServiceV1 "http://org3" "service1" "$ORG3_DID" + +# Wait for NodeB to contain 8 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 8 10 + +echo "------------------------------------" +echo "Restoring backup to nodeA..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml stop +rm -r ./nodeA/data +mv ./nodeA/backup ./nodeA/data +docker compose -f docker-compose-pre-migration.yml up nodeA --wait nodeA + +echo "------------------------------------" +echo "Adding right branch..." +echo "------------------------------------" +addServiceV1 "http://vendor" "service2" "$VENDOR_DID" +addServiceV1 "http://org1" "service2" "$ORG1_DID" +addServiceV1 "http://org2" "service2" "$ORG2_DID" +addVerificationMethodV1 "$ORG3_DID" + +# Check NodeA contains 8 transactions, nodeB is offline +waitForTXCount "NodeA" "http://localhost:18081/status/diagnostics" 8 10 + +echo "------------------------------------" +echo "Syncing right branch..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml up --wait nodeB +# sync left and right branch through nodeB to create document conflicts on all DIDs + +# Wait for NodeB to contain 12 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 12 10 + +echo "------------------------------------" +echo "Fix some DID document conflicts..." +echo "------------------------------------" +addVerificationMethodV1 "$VENDOR_DID" +addServiceV1 "http://org3" "service2" "$ORG3_DID" +# ORG1 is in conflicted state +# ORG2 is conflicted but deactivated + +# Wait for NodeB to contain 14 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 14 10 + +echo "------------------------------------" +echo "Upgrade nodeA to v6..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml down +docker compose -f docker-compose-post-migration.yml up --wait nodeA nodeB +# controller migration: +2 transactions: remove controllers from ORG1 and ORG3 + +# Wait for NodeB to contain 16 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 16 10 + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose -f docker-compose-post-migration.yml stop + +echo "------------------------------------" +echo "Verifying migration results..." +echo "------------------------------------" +# all 'waitForTXCount' calls have confirmed the didstore is (likely to be) in the correct state. Now check SQL store. + +VENDOR_DID=$VENDOR_DID ORG1_DID=$ORG1_DID ORG2_DID=$ORG2_DID ORG3_DID=$ORG3_DID go test -v --tags=e2e_tests -count=1 . +if [ $? -ne 0 ]; then + echo "ERROR: test failure" + exitWithDockerLogs 1 docker-compose-post-migration.yml +fi \ No newline at end of file diff --git a/e2e-tests/migration/run-tests.sh b/e2e-tests/migration/run-tests.sh new file mode 100755 index 0000000000..e0f9acf11a --- /dev/null +++ b/e2e-tests/migration/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: Migrations v6 !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +./run-test.sh \ No newline at end of file diff --git a/e2e-tests/nuts-network/direct-wan/node-A/nuts.yaml b/e2e-tests/nuts-network/direct-wan/node-A/nuts.yaml index 27cd52bdb2..2e6cf08f25 100644 --- a/e2e-tests/nuts-network/direct-wan/node-A/nuts.yaml +++ b/e2e-tests/nuts-network/direct-wan/node-A/nuts.yaml @@ -17,3 +17,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" \ No newline at end of file diff --git a/e2e-tests/nuts-network/direct-wan/node-B/nuts.yaml b/e2e-tests/nuts-network/direct-wan/node-B/nuts.yaml index 17c5bfdc6d..97ad4019cb 100644 --- a/e2e-tests/nuts-network/direct-wan/node-B/nuts.yaml +++ b/e2e-tests/nuts-network/direct-wan/node-B/nuts.yaml @@ -18,3 +18,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/private-transactions/node-A/nuts.yaml b/e2e-tests/nuts-network/private-transactions/node-A/nuts.yaml index aaa9186c0f..c4d541af9e 100644 --- a/e2e-tests/nuts-network/private-transactions/node-A/nuts.yaml +++ b/e2e-tests/nuts-network/private-transactions/node-A/nuts.yaml @@ -26,3 +26,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 500 +storage: + sql: + connection: "sqlite:file:/opt/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/private-transactions/node-B/nuts.yaml b/e2e-tests/nuts-network/private-transactions/node-B/nuts.yaml index 04ab35b07d..71573460a3 100644 --- a/e2e-tests/nuts-network/private-transactions/node-B/nuts.yaml +++ b/e2e-tests/nuts-network/private-transactions/node-B/nuts.yaml @@ -26,3 +26,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 450 +storage: + sql: + connection: "sqlite:file:/opt/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/ssl-offloading/haproxy/node-A/nuts.yaml b/e2e-tests/nuts-network/ssl-offloading/haproxy/node-A/nuts.yaml index 6d34ac964d..945bf02d87 100644 --- a/e2e-tests/nuts-network/ssl-offloading/haproxy/node-A/nuts.yaml +++ b/e2e-tests/nuts-network/ssl-offloading/haproxy/node-A/nuts.yaml @@ -21,3 +21,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 250 +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/ssl-offloading/haproxy/node-B/nuts.yaml b/e2e-tests/nuts-network/ssl-offloading/haproxy/node-B/nuts.yaml index 0840505104..e70a528603 100644 --- a/e2e-tests/nuts-network/ssl-offloading/haproxy/node-B/nuts.yaml +++ b/e2e-tests/nuts-network/ssl-offloading/haproxy/node-B/nuts.yaml @@ -22,3 +22,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 250 +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/ssl-offloading/nginx/node-A/nuts.yaml b/e2e-tests/nuts-network/ssl-offloading/nginx/node-A/nuts.yaml index 6d34ac964d..945bf02d87 100644 --- a/e2e-tests/nuts-network/ssl-offloading/nginx/node-A/nuts.yaml +++ b/e2e-tests/nuts-network/ssl-offloading/nginx/node-A/nuts.yaml @@ -21,3 +21,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 250 +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/ssl-offloading/nginx/node-B/nuts.yaml b/e2e-tests/nuts-network/ssl-offloading/nginx/node-B/nuts.yaml index d0644ede0c..eb04c225cb 100644 --- a/e2e-tests/nuts-network/ssl-offloading/nginx/node-B/nuts.yaml +++ b/e2e-tests/nuts-network/ssl-offloading/nginx/node-B/nuts.yaml @@ -22,3 +22,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 250 +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/ssl-pass-through/node-A/nuts.yaml b/e2e-tests/nuts-network/ssl-pass-through/node-A/nuts.yaml index 22143a2bf0..ad5b3d2d55 100644 --- a/e2e-tests/nuts-network/ssl-pass-through/node-A/nuts.yaml +++ b/e2e-tests/nuts-network/ssl-pass-through/node-A/nuts.yaml @@ -18,3 +18,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/nuts-network/ssl-pass-through/node-B/nuts.yaml b/e2e-tests/nuts-network/ssl-pass-through/node-B/nuts.yaml index 208c79958f..78482d93fc 100644 --- a/e2e-tests/nuts-network/ssl-pass-through/node-B/nuts.yaml +++ b/e2e-tests/nuts-network/ssl-pass-through/node-B/nuts.yaml @@ -19,3 +19,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml b/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml index 2496034a39..e6e6a13d66 100644 --- a/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml +++ b/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml @@ -1,4 +1,5 @@ url: https://nodeA +didmethods: ["web"] verbosity: debug strictmode: false internalratelimiter: false @@ -14,7 +15,4 @@ auth: authorizationendpoint: enabled: true policy: - directory: /opt/nuts/policies -vdr: - didmethods: - - web \ No newline at end of file + directory: /opt/nuts/policies \ No newline at end of file diff --git a/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml b/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml index b7d5d86c4c..ae8598702b 100644 --- a/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml +++ b/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml @@ -1,4 +1,5 @@ url: https://nodeB +didmethods: ["web"] verbosity: debug strictmode: false internalratelimiter: false @@ -17,7 +18,4 @@ policy: tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem - certkeyfile: /opt/nuts/certificate-and-key.pem -vdr: - didmethods: - - web \ No newline at end of file + certkeyfile: /opt/nuts/certificate-and-key.pem \ No newline at end of file diff --git a/e2e-tests/oauth-flow/rfc002/node-A/nuts.yaml b/e2e-tests/oauth-flow/rfc002/node-A/nuts.yaml index 4ba88057d5..18055e8729 100644 --- a/e2e-tests/oauth-flow/rfc002/node-A/nuts.yaml +++ b/e2e-tests/oauth-flow/rfc002/node-A/nuts.yaml @@ -1,4 +1,5 @@ url: http://node-A +didmethods: ["nuts"] verbosity: debug strictmode: false internalratelimiter: false @@ -18,6 +19,3 @@ tls: network: v2: gossipinterval: 500 -vdr: - didmethods: - - nuts diff --git a/e2e-tests/oauth-flow/rfc002/node-B/nuts.yaml b/e2e-tests/oauth-flow/rfc002/node-B/nuts.yaml index 2aea7c3f34..d14c7e50ab 100644 --- a/e2e-tests/oauth-flow/rfc002/node-B/nuts.yaml +++ b/e2e-tests/oauth-flow/rfc002/node-B/nuts.yaml @@ -1,4 +1,5 @@ url: http://node-b +didmethods: ["nuts"] verbosity: debug strictmode: false internalratelimiter: false @@ -18,7 +19,4 @@ tls: network: bootstrapnodes: nodeA-backend:5555 v2: - gossipinterval: 400 -vdr: - didmethods: - - nuts \ No newline at end of file + gossipinterval: 400 \ No newline at end of file diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh index 86c7273d7a..f7aebaabe3 100755 --- a/e2e-tests/oauth-flow/rfc021/do-test.sh +++ b/e2e-tests/oauth-flow/rfc021/do-test.sh @@ -45,16 +45,16 @@ echo Vendor B DID: $VENDOR_B_DID # Issue NutsOrganizationCredential for Vendor B REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"withStatusList2021Revocation\": true}" -RESPONSE=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") -if echo $RESPONSE | grep -q "VerifiableCredential"; then +VENDOR_B_CREDENTIAL=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") +if echo $VENDOR_B_CREDENTIAL | grep -q "VerifiableCredential"; then echo "VC issued" else echo "FAILED: Could not issue NutsOrganizationCredential to node-B" 1>&2 - echo $RESPONSE + echo $VENDOR_B_CREDENTIAL exitWithDockerLogs 1 fi -RESPONSE=$(echo $RESPONSE | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/holder/vendorB/vc -H "Content-Type:application/json") +RESPONSE=$(echo $VENDOR_B_CREDENTIAL | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/holder/vendorB/vc -H "Content-Type:application/json") if echo $RESPONSE == ""; then echo "VC stored in wallet" else @@ -63,6 +63,22 @@ else exitWithDockerLogs 1 fi +# Test regression for https://github.com/nuts-foundation/nuts-node/issues/3451 +# (VCR: Status List can't be retrieved when using MS SQL Server) +# Get credential status URL from credentialStatus.statusListCredential property using jq +STATUS_LIST_CREDENTIAL=$(echo $VENDOR_B_CREDENTIAL | jq -r .credentialStatus.statusListCredential) +echo "Status list credential: $STATUS_LIST_CREDENTIAL" +# Get status list credential +RESPONSE=$($db_dc exec nodeB-backend curl -s -k $STATUS_LIST_CREDENTIAL) +# Check response HTTP 200 OK +if echo $RESPONSE | grep -q "\"id\":\"$STATUS_LIST_CREDENTIAL\"" ; then + echo "Status list credential retrieved" +else + echo "FAILED: Could not retrieve status list credential" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + # Register vendor B on Discovery Service echo "Registering vendor B on Discovery Service..." REQUEST="{\"registrationParameters\":{\"key\":\"value\"}}" @@ -75,6 +91,29 @@ else exitWithDockerLogs 1 fi +# Test regression for https://github.com/nuts-foundation/nuts-node/issues/3442 +# (Discovery: GetActivationStatus fails on MS SQL Server) +echo "Getting activation status from Discovery Service..." +RESPONSE=$(curl -s http://localhost:28081/internal/discovery/v1/e2e-test/vendorB) +echo $RESPONSE +if echo $RESPONSE | grep -q "true"; then + echo "Activation status OK" +else + echo "FAILED: Could not get activation status of vendor B on Discovery Service" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +# Search for registration using wildcards, results in complicated DB query +RESPONSE=$(curl -s --insecure http://localhost:28081/internal/discovery/v1/e2e-test?credentialSubject.organization.name=*) +NUM_ITEMS=$(echo $RESPONSE | jq length) +if [ $NUM_ITEMS -eq 1 ]; then + echo "Registration found" +else + echo "FAILED: Could not find registration" 1>&2 + exitWithDockerLogs 1 +fi + echo "---------------------------------------" echo "Perform OAuth 2.0 rfc021 flow..." echo "---------------------------------------" @@ -154,8 +193,6 @@ else exitWithDockerLogs 1 fi - - echo "------------------------------------" echo "Retrieving data..." echo "------------------------------------" @@ -168,6 +205,33 @@ else exitWithDockerLogs 1 fi +echo "------------------------------------" +echo "Revoking credential..." +echo "------------------------------------" +# revoke credential +VENDOR_B_CREDENTIAL_ID=$(echo $VENDOR_B_CREDENTIAL | jq -r .id) +echo $VENDOR_B_CREDENTIAL_ID +RESPONSE=$(curl -s -X DELETE "http://localhost:28081/internal/vcr/v2/issuer/vc/${VENDOR_B_CREDENTIAL_ID//#/%23}") +if [ -z "${RESPONSE}" ]; then + echo "VendorB NutsOrganizationCredential revoked" +else + echo "FAILED: NutsOrganizationCredential not revoked" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "------------------------------------" +echo "Retrieving data fails..." +echo "------------------------------------" +RESPONSE=$($db_dc exec nodeB curl --http1.1 --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/resource -H "Authorization: DPoP $ACCESS_TOKEN" -H "DPoP: $DPOP") +if [ "${RESPONSE}" == "Unauthorized" ]; then + echo "Access denied!" +else + echo "FAILED: Retrieved data with revoked credential" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + echo "------------------------------------" echo "Stopping Docker containers..." echo "------------------------------------" diff --git a/e2e-tests/oauth-flow/rfc021/shared/discovery/definition.json b/e2e-tests/oauth-flow/rfc021/shared/discovery/definition.json index e7b38ad1c2..f43a884224 100644 --- a/e2e-tests/oauth-flow/rfc021/shared/discovery/definition.json +++ b/e2e-tests/oauth-flow/rfc021/shared/discovery/definition.json @@ -27,7 +27,8 @@ }, { "path": [ - "$.credentialSubject.organization.name" + "$.credentialSubject.organization.name", + "$.credentialSubject[0].organization.name" ], "filter": { "type": "string" @@ -35,7 +36,8 @@ }, { "path": [ - "$.credentialSubject.organization.city" + "$.credentialSubject.organization.city", + "$.credentialSubject[0].organization.city" ], "filter": { "type": "string" diff --git a/e2e-tests/oauth-flow/statuslist2021/node-A/nuts.yaml b/e2e-tests/oauth-flow/statuslist2021/node-A/nuts.yaml index 8ec57a2472..76f6782870 100644 --- a/e2e-tests/oauth-flow/statuslist2021/node-A/nuts.yaml +++ b/e2e-tests/oauth-flow/statuslist2021/node-A/nuts.yaml @@ -1,4 +1,5 @@ url: https://nodeA +didmethods: ["web"] verbosity: debug strictmode: false internalratelimiter: false @@ -11,6 +12,3 @@ auth: - dummy irma: autoupdateschemas: false -vdr: - didmethods: - - web diff --git a/e2e-tests/oauth-flow/statuslist2021/node-B/nuts.yaml b/e2e-tests/oauth-flow/statuslist2021/node-B/nuts.yaml index 698a34450b..b8e84cece9 100644 --- a/e2e-tests/oauth-flow/statuslist2021/node-B/nuts.yaml +++ b/e2e-tests/oauth-flow/statuslist2021/node-B/nuts.yaml @@ -1,4 +1,5 @@ url: https://nodeB +didmethods: ["web"] verbosity: debug strictmode: false internalratelimiter: false @@ -10,7 +11,4 @@ auth: contractvalidators: - dummy irma: - autoupdateschemas: false -vdr: - didmethods: - - web \ No newline at end of file + autoupdateschemas: false \ No newline at end of file diff --git a/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml b/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml index b18a53712d..467ba49a0a 100644 --- a/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml +++ b/e2e-tests/openid4vci/issuer-initiated/node-A/nuts.yaml @@ -24,3 +24,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 500 +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml b/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml index a1aadf9a42..b0acca9808 100644 --- a/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml +++ b/e2e-tests/openid4vci/issuer-initiated/node-B/nuts.yaml @@ -25,3 +25,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 450 +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml b/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml index c032185df7..65840f71c0 100644 --- a/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml +++ b/e2e-tests/openid4vci/network-issuance/node-A/nuts.yaml @@ -28,3 +28,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 500 +storage: + sql: + connection: "sqlite:file:/opt/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml b/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml index b64d43eebd..42a4e5b9b2 100644 --- a/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml +++ b/e2e-tests/openid4vci/network-issuance/node-B/nuts.yaml @@ -29,3 +29,6 @@ network: grpcaddr: :5555 v2: gossipinterval: 450 +storage: + sql: + connection: "sqlite:file:/opt/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/ops/create-subject/docker-compose.yml b/e2e-tests/ops/create-subject/docker-compose.yml index 28d1a2ee4f..0e105c0afc 100644 --- a/e2e-tests/ops/create-subject/docker-compose.yml +++ b/e2e-tests/ops/create-subject/docker-compose.yml @@ -3,9 +3,5 @@ services: image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" ports: - "18081:8081" - environment: - NUTS_CONFIGFILE: /opt/nuts/nuts.yaml - volumes: - - "./nuts.yaml:/opt/nuts/nuts.yaml:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file diff --git a/e2e-tests/ops/key-rotation/node-A/nuts.yaml b/e2e-tests/ops/key-rotation/node-A/nuts.yaml index 9e1ac04ca1..1b2824f653 100644 --- a/e2e-tests/ops/key-rotation/node-A/nuts.yaml +++ b/e2e-tests/ops/key-rotation/node-A/nuts.yaml @@ -15,3 +15,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/ops/key-rotation/node-B/nuts.yaml b/e2e-tests/ops/key-rotation/node-B/nuts.yaml index 11b74208a2..158918d73f 100644 --- a/e2e-tests/ops/key-rotation/node-B/nuts.yaml +++ b/e2e-tests/ops/key-rotation/node-B/nuts.yaml @@ -16,3 +16,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/run-tests.sh b/e2e-tests/run-tests.sh index c6df3fb2ae..98146cf5b6 100755 --- a/e2e-tests/run-tests.sh +++ b/e2e-tests/run-tests.sh @@ -57,3 +57,10 @@ echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" pushd discovery ./run-tests.sh popd + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!! Running test suite: Migration !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +pushd migration +./run-tests.sh +popd diff --git a/e2e-tests/storage/redis/node-A/nuts.yaml b/e2e-tests/storage/redis/node-A/nuts.yaml index c782f6aa91..92942772ee 100644 --- a/e2e-tests/storage/redis/node-A/nuts.yaml +++ b/e2e-tests/storage/redis/node-A/nuts.yaml @@ -21,3 +21,5 @@ storage: redis: address: redis:6379 database: nodeA + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/storage/redis/node-B/nuts.yaml b/e2e-tests/storage/redis/node-B/nuts.yaml index 69f9ee7913..b17a2e4e95 100644 --- a/e2e-tests/storage/redis/node-B/nuts.yaml +++ b/e2e-tests/storage/redis/node-B/nuts.yaml @@ -22,3 +22,5 @@ storage: redis: address: redis:6379 database: nodeB + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/storage/vault/nuts.yaml b/e2e-tests/storage/vault/nuts.yaml index b8e877c3d4..7a014549d4 100644 --- a/e2e-tests/storage/vault/nuts.yaml +++ b/e2e-tests/storage/vault/nuts.yaml @@ -19,3 +19,6 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem +storage: + sql: + connection: "sqlite:file:/nuts/data/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)" diff --git a/e2e-tests/util.sh b/e2e-tests/util.sh index e1306da1b2..b140fb94ce 100644 --- a/e2e-tests/util.sh +++ b/e2e-tests/util.sh @@ -57,10 +57,13 @@ function waitForDiagnostic { echo "" } +# exitWithDockerLogs prints the logs of the containers and exits with a specified exit code +# Args: exit code; docker compose file to use for the logs (default: docker-compose.yml) function exitWithDockerLogs { EXIT_CODE=$1 - docker compose logs - docker compose down + COMPOSE_FILE="${2:-docker-compose.yml}" + docker compose -f $COMPOSE_FILE logs + docker compose -f $COMPOSE_FILE stop exit $EXIT_CODE } diff --git a/events/events_mock.go b/events/events_mock.go index 0b1844a34c..ede78e0759 100644 --- a/events/events_mock.go +++ b/events/events_mock.go @@ -19,6 +19,7 @@ import ( type MockEvent struct { ctrl *gomock.Controller recorder *MockEventMockRecorder + isgomock struct{} } // MockEventMockRecorder is the mock recorder for MockEvent. diff --git a/events/mock.go b/events/mock.go index 4e7a7aef88..98512e859e 100644 --- a/events/mock.go +++ b/events/mock.go @@ -21,6 +21,7 @@ import ( type MockConn struct { ctrl *gomock.Controller recorder *MockConnMockRecorder + isgomock struct{} } // MockConnMockRecorder is the mock recorder for MockConn. @@ -75,6 +76,7 @@ func (mr *MockConnMockRecorder) JetStream(opts ...any) *gomock.Call { type MockJetStreamContext struct { ctrl *gomock.Controller recorder *MockJetStreamContextMockRecorder + isgomock struct{} } // MockJetStreamContextMockRecorder is the mock recorder for MockJetStreamContext. @@ -915,6 +917,7 @@ func (mr *MockJetStreamContextMockRecorder) UpdateStream(cfg any, opts ...any) * type MockConnectionPool struct { ctrl *gomock.Controller recorder *MockConnectionPoolMockRecorder + isgomock struct{} } // MockConnectionPoolMockRecorder is the mock recorder for MockConnectionPool. diff --git a/go.mod b/go.mod index 7886813a05..0770e663b1 100644 --- a/go.mod +++ b/go.mod @@ -3,40 +3,45 @@ module github.com/nuts-foundation/nuts-node go 1.23 require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 github.com/alicebob/miniredis/v2 v2.33.0 github.com/avast/retry-go/v4 v4.6.0 github.com/cbroglie/mustache v1.4.0 - github.com/chromedp/chromedp v0.10.0 + github.com/chromedp/chromedp v0.11.1 github.com/dlclark/regexp2 v1.11.4 - github.com/glebarez/sqlite v1.11.0 github.com/go-redis/redismock/v9 v9.2.0 github.com/goodsign/monday v1.0.2 github.com/google/uuid v1.6.0 github.com/hashicorp/vault/api v1.15.0 - github.com/knadh/koanf v1.5.0 + github.com/knadh/koanf/parsers/yaml v0.1.0 + github.com/knadh/koanf/providers/env v1.0.0 + github.com/knadh/koanf/providers/file v1.1.2 + github.com/knadh/koanf/providers/posflag v0.1.0 + github.com/knadh/koanf/providers/structs v0.1.0 + github.com/knadh/koanf/v2 v2.1.1 github.com/labstack/echo/v4 v4.12.0 - github.com/lestrrat-go/jwx/v2 v2.1.1 + github.com/lestrrat-go/jwx/v2 v2.1.2 github.com/magiconair/properties v1.8.7 github.com/mdp/qrterminal/v3 v3.2.0 github.com/mr-tron/base58 v1.2.0 github.com/multiformats/go-multicodec v0.9.0 - github.com/nats-io/nats-server/v2 v2.10.21 + github.com/nats-io/nats-server/v2 v2.10.22 github.com/nats-io/nats.go v1.37.0 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.14.0 + github.com/nuts-foundation/go-did v0.15.0 github.com/nuts-foundation/go-leia/v4 v4.0.3 - github.com/nuts-foundation/go-stoabs v1.9.0 + github.com/nuts-foundation/go-stoabs v1.10.0 + github.com/nuts-foundation/sqlite v1.0.0 // check the oapi-codegen tool version in the makefile when upgrading the runtime github.com/oapi-codegen/runtime v1.1.1 github.com/piprate/json-gold v0.5.1-0.20230111113000-6ddbe6e6f19f - github.com/pressly/goose/v3 v3.22.0 + github.com/pressly/goose/v3 v3.22.1 github.com/privacybydesign/irmago v0.16.0 - github.com/prometheus/client_golang v1.20.4 + github.com/prometheus/client_golang v1.20.5 github.com/prometheus/client_model v0.6.1 - github.com/redis/go-redis/v9 v9.6.1 + github.com/redis/go-redis/v9 v9.7.0 github.com/santhosh-tekuri/jsonschema v1.2.4 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.1 @@ -46,11 +51,11 @@ require ( go.etcd.io/bbolt v1.3.11 go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.0 - go.uber.org/mock v0.4.0 - golang.org/x/crypto v0.27.0 - golang.org/x/time v0.6.0 - google.golang.org/grpc v1.67.0 - google.golang.org/protobuf v1.34.2 + go.uber.org/mock v0.5.0 + golang.org/x/crypto v0.28.0 + golang.org/x/time v0.7.0 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.35.1 gopkg.in/Regis24GmbH/go-phonetics.v2 v2.0.3 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 @@ -62,12 +67,12 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect github.com/PaesslerAG/gval v1.2.2 // indirect github.com/alexandrevicenzi/go-sse v1.6.0 // indirect - github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bwesterb/byteswriter v1.0.0 // indirect @@ -78,8 +83,8 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 // indirect - github.com/chromedp/sysutil v1.0.0 // indirect + github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -87,9 +92,8 @@ require ( github.com/edsrzf/mmap-go v1.1.0 // indirect github.com/eknkc/basex v1.0.1 // indirect github.com/fatih/structs v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor v1.5.1 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-chi/chi/v5 v5.0.10 // indirect github.com/go-co-op/gocron v1.28.3 // indirect github.com/go-errors/errors v1.4.2 // indirect @@ -97,6 +101,7 @@ require ( github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-redsync/redsync/v4 v4.13.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect @@ -106,8 +111,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -119,14 +122,14 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.6.0 // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect @@ -183,19 +186,25 @@ require ( github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - go.opencensus.io v0.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.28.0 // indirect + golang.org/x/net v0.30.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect gorm.io/gorm v1.25.12 - modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.32.0 // indirect + modernc.org/sqlite v1.33.1 rsc.io/qr v0.2.0 // indirect ) + +require ( + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum index de75e72b31..ce11ff6b8e 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,33 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0/go.mod h1:YL1xnZ6QejvQHWJrX/AvhFl4WW4rqHVoKspWNVwFk0M= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0/go.mod h1:Q28U+75mpCaSCDowNEmhIo/rmgdkqmkmzI7N6TGR4UY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.2.0 h1:fKSH2aGSnt/ibGvis2f0gD3Lp10bzKr92FQxKutoNDc= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.2.0/go.mod h1:TPR8de/4RyFL97NXnpjtIaLZFR0eQxlQeXbk7EKIrbw= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0/go.mod h1:cw4zVQgBby0Z5f2v0itn6se2dDP17nTjbZFXW5uPyHA= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E= @@ -35,39 +36,18 @@ github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 h1:XIsQOSBJi/9Bexr+rjUpuYi0IkQ+YqNKKlE7Yt/sw9Q= github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97/go.mod h1:zTyVtYhYjcHpfCtqnCMxejgp0pEEwb/xJzhn05NrkJk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alexandrevicenzi/go-sse v1.6.0 h1:3KvOzpuY7UrbqZgAtOEmub9/V5ykr7Myudw+PA+H1Ik= github.com/alexandrevicenzi/go-sse v1.6.0/go.mod h1:jdrNAhMgVqP7OfcUuM8eJx0sOY17wc+girs5utpFZUU= -github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= -github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT0m65DJ+Wo= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA= github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE= -github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw= -github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ= -github.com/aws/aws-sdk-go-v2/service/appconfig v1.4.2/go.mod h1:FZ3HkCe+b10uFZZkFdvf98LHW21k49W8o8J366lqVKY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8= -github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk= -github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g= -github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -90,23 +70,16 @@ github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprY github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335 h1:bATMoZLH2QGct1kzDxfmeBUQI/QhQvB0mBrOTct+YlQ= -github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E= -github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE= -github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= -github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU= +github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= +github.com/chromedp/chromedp v0.11.1 h1:Spca8egFqUlv+JDW+yIs+ijlHlJDPufgrfXPwtq6NMs= +github.com/chromedp/chromedp v0.11.1/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -114,10 +87,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/dgraph-io/badger/v4 v4.1.0 h1:E38jc0f+RATYrycSUf9LMv/t47XAy+3CApyYSq4APOQ= -github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg= -github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= -github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgraph-io/badger/v4 v4.3.1 h1:7r5wKqmoRpGgSxqa0S/nGdpOpvvzuREGPLSua73C8tw= +github.com/dgraph-io/badger/v4 v4.3.1/go.mod h1:oObz97DImXpd6O/Dt8BqdKLLTDmEmarAimo72VV5whQ= +github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84= +github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= @@ -131,27 +104,15 @@ github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY= github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= -github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor v1.5.1 h1:XjQWBgdmQyqimslUh5r4tUGmoqzHmBFQOImkWGi2awg= github.com/fxamacker/cbor v1.5.1/go.mod h1:3aPGItF174ni7dDzd6JZ206H8cmr4GDNBGpPa971zsU= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-co-op/gocron v1.28.3 h1:swTsge6u/1Ei51b9VLMz/YTzEzWpbsk5SiR7m5fklTI= @@ -160,13 +121,6 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI= @@ -180,10 +134,10 @@ github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukd github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -192,8 +146,6 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= @@ -208,88 +160,38 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= -github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= github.com/goodsign/monday v1.0.2 h1:k8kRMkCRVfCTWOU4dRfRgneQsWlB1+mJd3MxG0lGLzQ= github.com/goodsign/monday v1.0.2/go.mod h1:r4T4breXpoFwspQNM+u2sLxJb2zyTaxVGqUfTBjWOu8= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= -github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= -github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= @@ -297,44 +199,26 @@ github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3 github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= -github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= -github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= -github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M= -github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs= -github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= @@ -345,31 +229,28 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs= -github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= +github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= +github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0= +github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak= +github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w= +github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= +github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U= +github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= +github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0= +github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE= +github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= +github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -394,8 +275,8 @@ github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCG github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI32E= -github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0= +github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc= +github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -403,19 +284,12 @@ github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3v github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -425,8 +299,6 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyex github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g= github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= @@ -435,27 +307,16 @@ github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1 github.com/minio/sha256-simd v0.1.1 h1:5QHSlgo3nt5yKOJrC7W8w7X+NFl8cMPZm96iu8kKUJU= github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= @@ -473,12 +334,10 @@ github.com/multiformats/go-multihash v0.0.11 h1:yEyBxwoR/7vBM5NfLVXRnpQNVLrMhpS6 github.com/multiformats/go-multihash v0.0.11/go.mod h1:LXRDJcYYY+9BjlsFe6i5LV7uekf0OoEJdnRmitUshxk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt/v2 v2.5.8 h1:uvdSzwWiEGWGXf+0Q+70qv6AQdvcvxrv9hPM0RiPamE= github.com/nats-io/jwt/v2 v2.5.8/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A= -github.com/nats-io/nats-server/v2 v2.10.21 h1:gfG6T06wBdI25XyY2IsauarOc2srWoFxxfsOKjrzoRA= -github.com/nats-io/nats-server/v2 v2.10.21/go.mod h1:I1YxSAEWbXCfy0bthwvNb5X43WwIWMz7gx5ZVPDr5Rc= +github.com/nats-io/nats-server/v2 v2.10.22 h1:Yt63BGu2c3DdMoBZNcR6pjGQwk/asrKU7VX846ibxDA= +github.com/nats-io/nats-server/v2 v2.10.22/go.mod h1:X/m1ye9NYansUXYFrbcDwUi/blHkrgHh2rgCJaakonk= github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE= github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= @@ -489,100 +348,72 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAmxBiA= github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI= -github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80icUxWHwE1MrIOOEK5rxrtyKOgZeq5Iu1IjAEkggTY= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM= -github.com/nuts-foundation/go-did v0.14.0 h1:Y1tuQCC2xmDX1bdXQS9iquwzJgcT1zcJxbZkqC5Dfac= -github.com/nuts-foundation/go-did v0.14.0/go.mod h1:dQm9b2dYUnhgVW1FmpAi5nNe0mfIrnxM3EaQx4GsDhI= +github.com/nuts-foundation/go-did v0.15.0 h1:aNl6KC8jiyRJGl9PPKFBboLLC0wUm5h+tjE1UBDQEPw= +github.com/nuts-foundation/go-did v0.15.0/go.mod h1:swjCJvcRxc+i1nyieIERWEb3vFb4N7iYC+qen2OIbNg= github.com/nuts-foundation/go-leia/v4 v4.0.3 h1:xNZznXWvcIwonXIDmpDDvF7KmP9BOK0MFt9ir3RD2gI= github.com/nuts-foundation/go-leia/v4 v4.0.3/go.mod h1:tYveGED8tSbQYhZNv2DVTc51c2zEWmSF+MG96PAtalY= -github.com/nuts-foundation/go-stoabs v1.9.0 h1:zK+ugfolaJYyBvGwsRuavLVdycXk4Yw/1gI+tz17lWQ= -github.com/nuts-foundation/go-stoabs v1.9.0/go.mod h1:htbUqSZiaihqAvJfHwtAbQusGaJtIeWpm1pmKjBYXlM= +github.com/nuts-foundation/go-stoabs v1.10.0 h1:mNzm9jgraMc69a8gTgteli8t1CMxr1+gyI7A9Eh0NDk= +github.com/nuts-foundation/go-stoabs v1.10.0/go.mod h1:So6S7ninucyJUU7I+JK1zcpoGDsZtd+jLXXacVtSWew= +github.com/nuts-foundation/sqlite v1.0.0 h1:gLKyVIHZqYfYpEy5Ji6vjNUH8rs0luiY3DWNcOSBBzM= +github.com/nuts-foundation/sqlite v1.0.0/go.mod h1:2GHDXCw5Sul9L3h8T5k0+558scm4ol4iXG+wVyYqueI= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/name v0.0.0-20180628100202-0fd16699aae1/go.mod h1:eD5JxqMiuNYyFNmyY9rkJ/slN8y59oEu4Ei7F8OoKWQ= -github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/piprate/json-gold v0.5.1-0.20230111113000-6ddbe6e6f19f h1:HlPa7RcxTCrva5izPfTEfvYecO7LTahgmMRD1Qp13xg= github.com/piprate/json-gold v0.5.1-0.20230111113000-6ddbe6e6f19f/go.mod h1:WZ501QQMbZZ+3pXFPhQKzNwS1+jls0oqov3uQ2WasLs= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= -github.com/pressly/goose/v3 v3.22.0 h1:wd/7kNiPTuNAztWun7iaB98DrhulbWPrzMAaw2DEZNw= -github.com/pressly/goose/v3 v3.22.0/go.mod h1:yJM3qwSj2pp7aAaCvso096sguezamNb2OBgxCnh/EYg= +github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc= +github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo= github.com/privacybydesign/gabi v0.0.0-20221212095008-68a086907750 h1:3RuYOQTlArQ6Uw2TgySusmZGluP+18WdQL56YSfkM3Q= github.com/privacybydesign/gabi v0.0.0-20221212095008-68a086907750/go.mod h1:QZI8hX8Ff2GfZ7UJuxyWw3nAGgt2s5+U4hxY6rmwQvs= github.com/privacybydesign/irmago v0.16.0 h1:PxIPRvpitxfJSocIRwIoYDSETarsopxtByZ5XZ3yzcE= github.com/privacybydesign/irmago v0.16.0/go.mod h1:++4PaFrN8MuRkunja/ID6cX+5KcATi7I0uVG+asI96c= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= -github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= -github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis= github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -594,9 +425,6 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50 h1:vgWWQM2SnMoO9BiUZ2WFAYuYF6U0jNss9Vn/PZoi+tU= github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50/go.mod h1:W/QHK9G0i5yrmHvej5+hhoFMXTSZIWHGQRcpbGgqV9s= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -608,13 +436,10 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -652,37 +477,25 @@ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJ github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= -go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= -go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -691,43 +504,20 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -735,60 +525,23 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220519141025-dcacdad47464/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -801,112 +554,58 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524210228-3d17549cdc6b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= -google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 h1:rz88vn1OH2B9kKorR+QCrcuw6WbizVwahU2Y9Q09xqU= gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3/go.mod h1:vJmfdx2L0+30M90zUd0GCjLV14Ip3ZgWR5+MV1qljOo= gopkg.in/Regis24GmbH/go-phonetics.v2 v2.0.3 h1:pSSZonNnrORBQXIm3kl6P9EQTNqVds9zszK/BXbOItg= gopkg.in/Regis24GmbH/go-phonetics.v2 v2.0.3/go.mod h1:5u3BxKhx1TujE5j4Jj53c3uNTRUqOlxM5I5c4zDhEjA= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= @@ -919,8 +618,6 @@ gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= @@ -941,8 +638,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s= -modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= @@ -951,4 +648,3 @@ rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= schneider.vip/problem v1.9.1 h1:HYdGPzbTHnNziF7cC4ftbn/eTrjSIXhKfricAMaLIMk= schneider.vip/problem v1.9.1/go.mod h1:6hLRfO1e1MQWdG23Kl5b3Yp5FSexE+YiGVqCkAp3HUQ= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/http/client/caching.go b/http/client/caching.go index 5226e4674c..9e4191bacd 100644 --- a/http/client/caching.go +++ b/http/client/caching.go @@ -32,8 +32,8 @@ import ( // DefaultCachingTransport is a http.RoundTripper that can be used as a default transport for HTTP clients. // If caching is enabled, it will cache responses according to RFC 7234. -// If caching is disabled, it will behave like http.DefaultTransport. -var DefaultCachingTransport = http.DefaultTransport +// If caching is disabled, it will behave like our safe http.SafeHttpTransport. +var DefaultCachingTransport http.RoundTripper // maxCacheTime is the maximum time responses are cached. // Even if the server responds with a longer cache time, responses are never cached longer than maxCacheTime. diff --git a/http/client/client.go b/http/client/client.go index 5ec00bef13..60d9c57b87 100644 --- a/http/client/client.go +++ b/http/client/client.go @@ -19,23 +19,59 @@ package client import ( + "bytes" "crypto/tls" "errors" + "fmt" + "github.com/nuts-foundation/nuts-node/core" + "io" "net/http" "time" ) +// SafeHttpTransport is a http.Transport that can be used as a default transport for HTTP clients. +var SafeHttpTransport *http.Transport + func init() { - httpTransport := http.DefaultTransport.(*http.Transport) - if httpTransport.TLSClientConfig == nil { - httpTransport.TLSClientConfig = &tls.Config{} + SafeHttpTransport = http.DefaultTransport.(*http.Transport).Clone() + if SafeHttpTransport.TLSClientConfig == nil { + SafeHttpTransport.TLSClientConfig = &tls.Config{} } - httpTransport.TLSClientConfig.MinVersion = tls.VersionTLS12 + SafeHttpTransport.TLSClientConfig.MinVersion = tls.VersionTLS12 + // to prevent slow responses from public clients to have significant impact (default was unlimited) + SafeHttpTransport.MaxConnsPerHost = 5 + // set DefaultCachingTransport to SafeHttpTransport so it is set even when caching is disabled + DefaultCachingTransport = SafeHttpTransport } // StrictMode is a flag that can be set to true to enable strict mode for the HTTP client. var StrictMode bool +// DefaultMaxHttpResponseSize is a default maximum size of an HTTP response body that will be read. +// Very large or unbounded HTTP responses can cause denial-of-service, so it's good to limit how much data is read. +// This of course heavily depends on the use case, but 1MB is a reasonable default. +const DefaultMaxHttpResponseSize = 1024 * 1024 + +// limitedReadAll reads the given reader until the DefaultMaxHttpResponseSize is reached. +// It returns an error if more data is available than DefaultMaxHttpResponseSize. +func limitedReadAll(reader io.Reader) ([]byte, error) { + result, err := io.ReadAll(io.LimitReader(reader, DefaultMaxHttpResponseSize+1)) + if len(result) > DefaultMaxHttpResponseSize { + return nil, fmt.Errorf("data to read exceeds max. safety limit of %d bytes", DefaultMaxHttpResponseSize) + } + return result, err +} + +// New creates a new HTTP client with the given timeout. +func New(timeout time.Duration) *StrictHTTPClient { + return &StrictHTTPClient{ + client: &http.Client{ + Transport: SafeHttpTransport, + Timeout: timeout, + }, + } +} + // NewWithCache creates a new HTTP client with the given timeout. // It uses the DefaultCachingTransport as the underlying transport. func NewWithCache(timeout time.Duration) *StrictHTTPClient { @@ -51,7 +87,7 @@ func NewWithCache(timeout time.Duration) *StrictHTTPClient { // It copies the http.DefaultTransport and sets the TLSClientConfig to the given tls.Config. // As such, it can't be used in conjunction with the CachingRoundTripper. func NewWithTLSConfig(timeout time.Duration, tlsConfig *tls.Config) *StrictHTTPClient { - transport := http.DefaultTransport.(*http.Transport).Clone() + transport := SafeHttpTransport.Clone() transport.TLSClientConfig = tlsConfig return &StrictHTTPClient{ client: &http.Client{ @@ -69,5 +105,17 @@ func (s *StrictHTTPClient) Do(req *http.Request) (*http.Response, error) { if StrictMode && req.URL.Scheme != "https" { return nil, errors.New("strictmode is enabled, but request is not over HTTPS") } - return s.client.Do(req) + req.Header.Set("User-Agent", core.UserAgent()) + result, err := s.client.Do(req) + if err != nil { + return nil, err + } + if result.Body != nil { + body, err := limitedReadAll(result.Body) + if err != nil { + return nil, err + } + result.Body = io.NopCloser(bytes.NewReader(body)) + } + return result, nil } diff --git a/http/client/client_test.go b/http/client/client_test.go index aad3b7bd1b..76d5c3d401 100644 --- a/http/client/client_test.go +++ b/http/client/client_test.go @@ -20,8 +20,14 @@ package client import ( "crypto/tls" + "fmt" "github.com/stretchr/testify/assert" - stdHttp "net/http" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" "testing" "time" ) @@ -34,7 +40,7 @@ func TestStrictHTTPClient(t *testing.T) { StrictMode = true client := NewWithCache(time.Second) - httpRequest, _ := stdHttp.NewRequest("GET", "http://example.com", nil) + httpRequest, _ := http.NewRequest("GET", "http://example.com", nil) _, err := client.Do(httpRequest) assert.EqualError(t, err, "strictmode is enabled, but request is not over HTTPS") @@ -46,7 +52,7 @@ func TestStrictHTTPClient(t *testing.T) { StrictMode = false client := NewWithCache(time.Second) - httpRequest, _ := stdHttp.NewRequest("GET", "http://example.com", nil) + httpRequest, _ := http.NewRequest("GET", "http://example.com", nil) _, err := client.Do(httpRequest) assert.NoError(t, err) @@ -60,7 +66,7 @@ func TestStrictHTTPClient(t *testing.T) { StrictMode = true client := NewWithCache(time.Second) - httpRequest, _ := stdHttp.NewRequest("GET", "http://example.com", nil) + httpRequest, _ := http.NewRequest("GET", "http://example.com", nil) _, err := client.Do(httpRequest) assert.EqualError(t, err, "strictmode is enabled, but request is not over HTTPS") @@ -70,7 +76,7 @@ func TestStrictHTTPClient(t *testing.T) { client := NewWithTLSConfig(time.Second, &tls.Config{ InsecureSkipVerify: true, }) - ts := client.client.Transport.(*stdHttp.Transport) + ts := client.client.Transport.(*http.Transport) assert.True(t, ts.TLSClientConfig.InsecureSkipVerify) }) }) @@ -80,10 +86,114 @@ func TestStrictHTTPClient(t *testing.T) { StrictMode = true client := NewWithCache(time.Second) - httpRequest, _ := stdHttp.NewRequest("GET", "http://example.com", nil) + httpRequest, _ := http.NewRequest("GET", "http://example.com", nil) _, err := client.Do(httpRequest) assert.EqualError(t, err, "strictmode is enabled, but request is not over HTTPS") assert.Equal(t, 0, rt.invocations) }) } + +func TestLimitedReadAll(t *testing.T) { + t.Run("less than limit", func(t *testing.T) { + data := strings.Repeat("a", 10) + result, err := limitedReadAll(strings.NewReader(data)) + + assert.NoError(t, err) + assert.Equal(t, []byte(data), result) + }) + t.Run("more than limit", func(t *testing.T) { + data := strings.Repeat("a", DefaultMaxHttpResponseSize+1) + result, err := limitedReadAll(strings.NewReader(data)) + + assert.EqualError(t, err, "data to read exceeds max. safety limit of 1048576 bytes") + assert.Nil(t, result) + }) +} + +func TestMaxConns(t *testing.T) { + oldStrictMode := StrictMode + StrictMode = false + t.Cleanup(func() { StrictMode = oldStrictMode }) + // Our safe http Transport has MaxConnsPerHost = 5 + // if we request 6 resources multiple times, we expect a max connection usage of 5 + + // counter for the number of concurrent requests + var counter atomic.Int32 + + // create a test server with 6 different url handlers + handler := http.NewServeMux() + createHandler := func(id int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + counter.Add(1) + assert.True(t, counter.Load() < 6) + _, _ = w.Write([]byte(fmt.Sprintf("%d", id))) + time.Sleep(time.Millisecond) // to allow for some parallel requests + counter.Add(-1) + } + } + handler.HandleFunc("/1", createHandler(1)) + handler.HandleFunc("/2", createHandler(2)) + handler.HandleFunc("/3", createHandler(3)) + handler.HandleFunc("/4", createHandler(4)) + handler.HandleFunc("/5", createHandler(5)) + handler.HandleFunc("/6", createHandler(6)) + + server := httptest.NewServer(handler) + defer server.Close() + client := New(time.Second) + + wg := sync.WaitGroup{} + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + request, _ := http.NewRequest("GET", fmt.Sprintf("%s/%d", server.URL, i%6), nil) + _, _ = client.Do(request) + }() + } + + wg.Wait() +} + +func TestCaching(t *testing.T) { + oldStrictMode := StrictMode + StrictMode = false + t.Cleanup(func() { StrictMode = oldStrictMode }) + // counter for the number of concurrent requests + var total atomic.Int32 + + // create a test server with 6 different url handlers + handler := http.NewServeMux() + createHandler := func(id int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + total.Add(1) + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", 5)) + _, _ = w.Write([]byte(fmt.Sprintf("%d", id))) + } + } + handler.HandleFunc("/1", createHandler(1)) + + server := httptest.NewServer(handler) + defer server.Close() + DefaultCachingTransport = NewCachingTransport(SafeHttpTransport, 1024*1024) + client := NewWithCache(time.Second) + + // fill cache + request, _ := http.NewRequest("GET", fmt.Sprintf("%s/1", server.URL), nil) + _, err := client.Do(request) + require.NoError(t, err) + + wg := sync.WaitGroup{} + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + req, _ := http.NewRequest("GET", fmt.Sprintf("%s/1", server.URL), nil) + _, _ = client.Do(req) + }() + } + wg.Wait() + + assert.Equal(t, int32(1), total.Load()) +} diff --git a/http/cmd/cmd.go b/http/cmd/cmd.go index 4eedaf8316..1721738ebc 100644 --- a/http/cmd/cmd.go +++ b/http/cmd/cmd.go @@ -35,6 +35,7 @@ func FlagSet() *pflag.FlagSet { flags.String("http.internal.auth.audience", defs.Internal.Auth.Audience, "Expected audience for JWT tokens (default: hostname)") flags.String("http.internal.auth.authorizedkeyspath", defs.Internal.Auth.AuthorizedKeysPath, "Path to an authorized_keys file for trusted JWT signers") flags.String("http.log", string(defs.Log), fmt.Sprintf("What to log about HTTP requests. Options are '%s', '%s' (log request method, URI, IP and response code), and '%s' (log the request and response body, in addition to the metadata). When debug vebosity is set the authorization headers are also logged when the request is fully logged.", http.LogNothingLevel, http.LogMetadataLevel, http.LogMetadataAndBodyLevel)) + flags.String("http.clientipheader", defs.ClientIPHeaderName, "Case-sensitive HTTP Header that contains the client IP used for audit logs. For the X-Forwarded-For header only link-local, loopback, and private IPs are excluded. Switch to X-Real-IP or a custom header if you see your own proxy/infra in the logs.") flags.Int("http.cache.maxbytes", defs.ResponseCacheSize, "HTTP client maximum size of the response cache in bytes. If 0, the HTTP client does not cache responses.") return flags diff --git a/http/config.go b/http/config.go index 4554802dfb..35cd5d7477 100644 --- a/http/config.go +++ b/http/config.go @@ -28,7 +28,8 @@ func DefaultConfig() Config { Public: PublicConfig{ Address: ":8080", }, - ResponseCacheSize: 10 * 1024 * 1024, // 10mb + ResponseCacheSize: 10 * 1024 * 1024, // 10mb + ClientIPHeaderName: "X-Forwarded-For", } } @@ -39,7 +40,8 @@ type Config struct { Public PublicConfig `koanf:"public"` Internal InternalConfig `koanf:"internal"` // ResponseCacheSize is the maximum number of bytes cached by HTTP clients. - ResponseCacheSize int `koanf:"cache.maxbytes"` + ResponseCacheSize int `koanf:"cache.maxbytes"` + ClientIPHeaderName string `koanf:"clientipheader"` } // PublicConfig contains the configuration for outside-facing HTTP endpoints. diff --git a/http/echo.go b/http/echo.go index dd8c07470f..cda8788a9e 100644 --- a/http/echo.go +++ b/http/echo.go @@ -72,7 +72,7 @@ type MultiEcho struct { // results in an error being returned. // If address wasn't used for another bind and thus leads to creating a new Echo server, it returns true. // If an existing Echo server is returned, it returns false. -func (c *MultiEcho) Bind(path string, address string, creatorFn func() (EchoServer, error)) error { +func (c *MultiEcho) Bind(path string, address string, creatorFn func(ipHeader string) (EchoServer, error), ipHeader string) error { if len(address) == 0 { return errors.New("empty address") } @@ -86,7 +86,7 @@ func (c *MultiEcho) Bind(path string, address string, creatorFn func() (EchoServ } c.binds[path] = address if _, addressBound := c.interfaces[address]; !addressBound { - server, err := creatorFn() + server, err := creatorFn(ipHeader) if err != nil { return err } diff --git a/http/echo_mock.go b/http/echo_mock.go index 284eb54e1b..592634ad96 100644 --- a/http/echo_mock.go +++ b/http/echo_mock.go @@ -21,6 +21,7 @@ import ( type MockEchoServer struct { ctrl *gomock.Controller recorder *MockEchoServerMockRecorder + isgomock struct{} } // MockEchoServerMockRecorder is the mock recorder for MockEchoServer. diff --git a/http/echo_test.go b/http/echo_test.go index cb353773b4..1dce7ff22d 100644 --- a/http/echo_test.go +++ b/http/echo_test.go @@ -32,18 +32,18 @@ func Test_MultiEcho_Bind(t *testing.T) { const defaultAddress = ":1323" t.Run("group already bound", func(t *testing.T) { m := NewMultiEcho() - err := m.Bind("", defaultAddress, func() (EchoServer, error) { + err := m.Bind("", defaultAddress, func(ipHeader string) (EchoServer, error) { return echo.New(), nil - }) + }, "header") require.NoError(t, err) - err = m.Bind("", defaultAddress, func() (EchoServer, error) { + err = m.Bind("", defaultAddress, func(ipHeader string) (EchoServer, error) { return echo.New(), nil - }) + }, "header") assert.EqualError(t, err, "http bind already exists: /") }) t.Run("error - group contains subpaths", func(t *testing.T) { m := NewMultiEcho() - err := m.Bind("internal/vdr", defaultAddress, nil) + err := m.Bind("internal/vdr", defaultAddress, nil, "") assert.EqualError(t, err, "bind can't contain subpaths: internal/vdr") }) } @@ -55,9 +55,9 @@ func Test_MultiEcho_Start(t *testing.T) { server.EXPECT().Start(gomock.Any()).Return(errors.New("unable to start")) m := NewMultiEcho() - m.Bind("group2", ":8080", func() (EchoServer, error) { + m.Bind("group2", ":8080", func(ipHeader string) (EchoServer, error) { return server, nil - }) + }, "header") err := m.Start() assert.EqualError(t, err, "unable to start") }) @@ -84,22 +84,22 @@ func Test_MultiEcho(t *testing.T) { // Bind interfaces m := NewMultiEcho() - err := m.Bind(RootPath, defaultAddress, func() (EchoServer, error) { + err := m.Bind(RootPath, defaultAddress, func(ipHeader string) (EchoServer, error) { return defaultServer, nil - }) + }, "header") require.NoError(t, err) - err = m.Bind("internal", "internal:8080", func() (EchoServer, error) { + err = m.Bind("internal", "internal:8080", func(ipHeader string) (EchoServer, error) { return internalServer, nil - }) + }, "header") require.NoError(t, err) - err = m.Bind("public", "public:8080", func() (EchoServer, error) { + err = m.Bind("public", "public:8080", func(ipHeader string) (EchoServer, error) { return publicServer, nil - }) + }, "header") require.NoError(t, err) - err = m.Bind("extra-public", "public:8080", func() (EchoServer, error) { + err = m.Bind("extra-public", "public:8080", func(ipHeader string) (EchoServer, error) { t.Fatal("should not be called!") return nil, nil - }) + }, "header") require.NoError(t, err) m.addFn(http.MethodPost, "/public/pub-endpoint", nil) @@ -129,9 +129,9 @@ func Test_MultiEcho_Methods(t *testing.T) { ) m := NewMultiEcho() - m.Bind(RootPath, ":1323", func() (EchoServer, error) { + m.Bind(RootPath, ":1323", func(ipHeader string) (EchoServer, error) { return defaultServer, nil - }) + }, "header") m.GET("/get", nil) m.POST("/post", nil) m.PUT("/put", nil) diff --git a/http/engine.go b/http/engine.go index d64941bbea..790f3da4b7 100644 --- a/http/engine.go +++ b/http/engine.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "github.com/nuts-foundation/nuts-node/http/client" + "net" "net/http" "os" "strings" @@ -79,12 +80,12 @@ func (h *Engine) Configure(serverConfig core.ServerConfig) error { h.server = NewMultiEcho() // Public endpoints - if err := h.server.Bind(RootPath, h.config.Public.Address, h.createEchoServer); err != nil { + if err := h.server.Bind(RootPath, h.config.Public.Address, h.createEchoServer, h.config.ClientIPHeaderName); err != nil { return err } // Internal endpoints for _, httpPath := range []string{"/internal", "/status", "/health", "/metrics"} { - if err := h.server.Bind(httpPath, h.config.Internal.Address, h.createEchoServer); err != nil { + if err := h.server.Bind(httpPath, h.config.Internal.Address, h.createEchoServer, h.config.ClientIPHeaderName); err != nil { return err } } @@ -98,11 +99,11 @@ func (h *Engine) configureClient(serverConfig core.ServerConfig) { client.StrictMode = serverConfig.Strictmode // Configure the HTTP caching client, if enabled. Set it to http.DefaultTransport so it can be used by any subsystem. if h.config.ResponseCacheSize > 0 { - client.DefaultCachingTransport = client.NewCachingTransport(http.DefaultTransport, h.config.ResponseCacheSize) + client.DefaultCachingTransport = client.NewCachingTransport(client.SafeHttpTransport, h.config.ResponseCacheSize) } } -func (h *Engine) createEchoServer() (EchoServer, error) { +func (h *Engine) createEchoServer(ipHeader string) (EchoServer, error) { echoServer := echo.New() echoServer.HideBanner = true echoServer.HidePort = true @@ -110,8 +111,15 @@ func (h *Engine) createEchoServer() (EchoServer, error) { // ErrorHandler echoServer.HTTPErrorHandler = core.CreateHTTPErrorHandler() - // Reverse proxies must set the X-Forwarded-For header to the original client IP. - echoServer.IPExtractor = echo.ExtractIPFromXFFHeader() + // Extract original client IP from configured header. + switch ipHeader { + case echo.HeaderXForwardedFor: + echoServer.IPExtractor = echo.ExtractIPFromXFFHeader() + case "": + echoServer.IPExtractor = echo.ExtractIPDirect() // sensible fallback; use source address from IPv4/IPv6 packet header if there is no HTTP header. + default: + echoServer.IPExtractor = extractIPFromCustomHeader(ipHeader) + } return &echoAdapter{ startFn: echoServer.Start, @@ -253,3 +261,25 @@ func (h Engine) applyAuthMiddleware(echoServer core.EchoRouter, path string, con return nil } + +// extractIPFromCustomHeader extracts an IP address from any custom header. +// If the header is missing or contains an invalid IP, the extractor returns the ip from the request. +// This is an altered version of echo.ExtractIPFromRealIPHeader() that does not check for trusted IPs. +func extractIPFromCustomHeader(ipHeader string) echo.IPExtractor { + extractIP := echo.ExtractIPDirect() + return func(req *http.Request) string { + directIP := extractIP(req) // source address from IPv4/IPv6 packet header + realIP := req.Header.Get(ipHeader) + if realIP == "" { + return directIP + } + + realIP = strings.TrimPrefix(realIP, "[") + realIP = strings.TrimSuffix(realIP, "]") + if rIP := net.ParseIP(realIP); rIP != nil { + return realIP + } + + return directIP + } +} diff --git a/http/engine_test.go b/http/engine_test.go index f0e38b2361..31d36d5e11 100644 --- a/http/engine_test.go +++ b/http/engine_test.go @@ -131,6 +131,7 @@ func TestEngine_Configure(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "http://"+engine.config.Internal.Address+securedPath, nil) log.Logger().Infof("requesting %v", request.URL.String()) request.Header.Set("Authorization", "Bearer "+string(serializedToken)) + request.Header.Set(engine.config.ClientIPHeaderName, "1.2.3.4") response, err := http.DefaultClient.Do(request) assert.NoError(t, err) @@ -254,6 +255,7 @@ func TestEngine_LoggingMiddleware(t *testing.T) { t.Run("requestLogger", func(t *testing.T) { engine := New(noop, nil) engine.config.Internal.Address = fmt.Sprintf("localhost:%d", test.FreeTCPPort()) + engine.config.ClientIPHeaderName = "X-Custom-Header" err := engine.Configure(*core.NewServerConfig()) require.NoError(t, err) @@ -283,6 +285,24 @@ func TestEngine_LoggingMiddleware(t *testing.T) { _, _ = http.Get("http://" + engine.config.Internal.Address + "/some-path") assert.Contains(t, output.String(), "HTTP request") }) + t.Run("ip log - custom header", func(t *testing.T) { + // Call to another, registered path is logged + output.Reset() + request, _ := http.NewRequest(http.MethodGet, "http://"+engine.config.Internal.Address+"/some-path", nil) + request.Header.Set(engine.config.ClientIPHeaderName, "1.2.3.4") + _, err = http.DefaultClient.Do(request) + require.NoError(t, err) + assert.Contains(t, output.String(), "remote_ip=1.2.3.4") + }) + t.Run("ip log - custom header missing", func(t *testing.T) { + // Call to another, registered path is logged + output.Reset() + request, _ := http.NewRequest(http.MethodGet, "http://"+engine.config.Internal.Address+"/some-path", nil) + request.Header.Set(engine.config.ClientIPHeaderName, "") + _, err = http.DefaultClient.Do(request) + require.NoError(t, err) + assert.Contains(t, output.String(), "remote_ip=127.0.0.1") + }) }) t.Run("bodyLogger", func(t *testing.T) { engine := New(noop, nil) diff --git a/http/user/session.go b/http/user/session.go index d000a43f6a..0830762caa 100644 --- a/http/user/session.go +++ b/http/user/session.go @@ -36,7 +36,7 @@ import ( "time" ) -var userSessionContextKey = struct{}{} +type userSessionContextKey = struct{} // userSessionCookieName is the name of the cookie used to store the user session. // It uses the __Secure prefix, that instructs the user agent to treat it as a secure cookie: @@ -94,7 +94,7 @@ func (u SessionMiddleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { return u.Store.Put(sessionID, sessionData) } // Session data is put in request context for access by API handlers - echoCtx.SetRequest(echoCtx.Request().WithContext(context.WithValue(echoCtx.Request().Context(), userSessionContextKey, sessionData))) + echoCtx.SetRequest(echoCtx.Request().WithContext(context.WithValue(echoCtx.Request().Context(), userSessionContextKey{}, sessionData))) return next(echoCtx) } @@ -166,7 +166,7 @@ func (u SessionMiddleware) createUserSessionCookie(sessionID string, path string // GetSession retrieves the user session from the request context. // If the user session is not found, an error is returned. func GetSession(ctx context.Context) (*Session, error) { - result, ok := ctx.Value(userSessionContextKey).(*Session) + result, ok := ctx.Value(userSessionContextKey{}).(*Session) if !ok { return nil, errors.New("no user session found") } diff --git a/http/user/test.go b/http/user/test.go index b7b3e96852..39c13ec4cd 100644 --- a/http/user/test.go +++ b/http/user/test.go @@ -28,5 +28,5 @@ func CreateTestSession(ctx context.Context, subjectID string) (context.Context, session.Save = func() error { return nil } - return context.WithValue(ctx, userSessionContextKey, session), session + return context.WithValue(ctx, userSessionContextKey{}, session), session } diff --git a/main_test.go b/main_test.go index 62d34e3a5a..45df58769b 100644 --- a/main_test.go +++ b/main_test.go @@ -22,9 +22,9 @@ import ( "context" "errors" "fmt" - "github.com/knadh/koanf" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/structs" + "github.com/knadh/koanf/v2" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/cmd" "github.com/nuts-foundation/nuts-node/core" @@ -32,9 +32,9 @@ import ( "github.com/nuts-foundation/nuts-node/events" httpEngine "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/network" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/test/pki" - "github.com/nuts-foundation/nuts-node/vdr" v1 "github.com/nuts-foundation/nuts-node/vdr/api/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -147,6 +147,17 @@ func startServer(testDirectory string, exitCallback func(), serverConfig core.Se if err != nil { panic(err) } + if serverConfig.Strictmode { + type modCfg struct { + Storage storage.Config `koanf:"storage"` + } + storageConfig := modCfg{Storage: storage.DefaultConfig()} + storageConfig.Storage.SQL.ConnectionString = fmt.Sprintf("sqlite:file:%s/sqlite.db?_pragma=foreign_keys(1)&journal_mode(WAL)", testDirectory) + err = koanfInstance.Load(structs.ProviderWithDelim(storageConfig, "koanf", "."), nil) + if err != nil { + panic(err) + } + } bytes, err := koanfInstance.Marshal(yamlParser) if err != nil { @@ -211,6 +222,7 @@ func getIntegrationTestConfig(t *testing.T, testDirectory string) (core.ServerCo config.TLS.CertFile = pki.CertificateFile(t) config.TLS.CertKeyFile = config.TLS.CertFile config.TLS.TrustStoreFile = pki.TruststoreFile(t) + config.DIDMethods = []string{"nuts"} config.Datadir = testDirectory @@ -230,16 +242,12 @@ func getIntegrationTestConfig(t *testing.T, testDirectory string) (core.ServerCo httpConfig.Internal.Address = fmt.Sprintf("localhost:%d", test.FreeTCPPort()) httpConfig.Public.Address = fmt.Sprintf("localhost:%d", test.FreeTCPPort()) - vdrConfig := vdr.DefaultConfig() - vdrConfig.DIDMethods = []string{"nuts"} - return config, ModuleConfig{ Network: networkConfig, Auth: authConfig, Crypto: cryptoConfig, Events: eventsConfig, HTTP: httpConfig, - VDR: vdrConfig, } } @@ -249,5 +257,4 @@ type ModuleConfig struct { Crypto crypto.Config `koanf:"crypto"` Events events.Config `koanf:"events"` HTTP httpEngine.Config `koanf:"http"` - VDR vdr.Config `koanf:"vdr"` } diff --git a/makefile b/makefile index 4e9f25e0d2..90ec891787 100644 --- a/makefile +++ b/makefile @@ -3,11 +3,11 @@ run-generators: gen-mocks gen-api gen-protobuf install-tools: - go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.3.0 - go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.1 - go install go.uber.org/mock/mockgen@v0.4.0 - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0 - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.57.2 + go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 + go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2 + go install go.uber.org/mock/mockgen@v0.5.0 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 gen-mocks: mockgen -destination=auth/mock.go -package=auth -source=auth/interface.go diff --git a/network/api/v1/api.go b/network/api/v1/api.go index 5127066a5a..8089f877c2 100644 --- a/network/api/v1/api.go +++ b/network/api/v1/api.go @@ -25,6 +25,7 @@ import ( "github.com/labstack/echo/v4" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/network/log" + "net/http" "time" "github.com/nuts-foundation/nuts-node/core" @@ -42,10 +43,19 @@ type Wrapper struct { func (a *Wrapper) Routes(router core.EchoRouter) { RegisterHandlers(router, NewStrictHandler(a, []StrictMiddlewareFunc{ + func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { // fast fail if did:nuts is disabled + return func(ctx echo.Context, args interface{}) (interface{}, error) { + if a.Service.Disabled() { + return nil, network.ErrDIDNutsDisabled + } + return f(ctx, args) + } + }, func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { return func(ctx echo.Context, request interface{}) (response interface{}, err error) { ctx.Set(core.OperationIDContextKey, operationID) ctx.Set(core.ModuleNameContextKey, network.ModuleName) + ctx.Set(core.StatusCodeResolverContextKey, a) return f(ctx, request) } }, @@ -55,6 +65,13 @@ func (a *Wrapper) Routes(router core.EchoRouter) { })) } +// ResolveStatusCode maps errors returned by this API to specific HTTP status codes. +func (a *Wrapper) ResolveStatusCode(err error) int { + return core.ResolveStatusCode(err, map[error]int{ + network.ErrDIDNutsDisabled: http.StatusBadRequest, + }) +} + // ListTransactions lists all transactions func (a *Wrapper) ListTransactions(_ context.Context, request ListTransactionsRequestObject) (ListTransactionsResponseObject, error) { // Parse the start/end params, which have default values diff --git a/network/api/v1/client.go b/network/api/v1/client.go index 66469d2651..a1d06a172a 100644 --- a/network/api/v1/client.go +++ b/network/api/v1/client.go @@ -129,7 +129,7 @@ func (hb HTTPClient) Reprocess(contentType string) error { } func (hb HTTPClient) client() ClientInterface { - response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateHTTPClient(hb.ClientConfig, hb.TokenGenerator))) + response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateInternalHTTPClient(hb.ClientConfig, hb.TokenGenerator))) if err != nil { panic(err) } diff --git a/network/api/v1/generated.go b/network/api/v1/generated.go index 24ab9dcd78..6b13243e3b 100644 --- a/network/api/v1/generated.go +++ b/network/api/v1/generated.go @@ -1,6 +1,6 @@ // Package v1 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v1 import ( diff --git a/network/dag/dag.go b/network/dag/dag.go index 05ad9e77b5..a8e6260019 100644 --- a/network/dag/dag.go +++ b/network/dag/dag.go @@ -96,69 +96,6 @@ func newDAG(db stoabs.KVStore) *dag { return &dag{db: db} } -func (d *dag) Migrate() error { - return d.db.Write(context.Background(), func(tx stoabs.WriteTx) error { - writer := tx.GetShelfWriter(metadataShelf) - // Migrate highest LC value - // Todo: remove after V5 release - _, err := writer.Get(stoabs.BytesKey(highestClockValue)) - if errors.Is(err, stoabs.ErrKeyNotFound) { - log.Logger().Info("Highest LC value not stored, migrating...") - highestLC := d.getHighestClockLegacy(tx) - err = d.setHighestClockValue(tx, highestLC) - } - if err != nil { - return err - } - - // Migrate number of TXs - // Todo: remove after V5 release - _, err = writer.Get(stoabs.BytesKey(numberOfTransactionsKey)) - if errors.Is(err, stoabs.ErrKeyNotFound) { - log.Logger().Info("Number of transactions not stored, migrating...") - numberOfTXs := d.getNumberOfTransactionsLegacy(tx) - err = d.setNumberOfTransactions(tx, numberOfTXs) - } - if err != nil { - return err - } - - // Migrate headsLegacy to single head - // Todo: remove after V6 release => then remove headsShelf - _, err = writer.Get(stoabs.BytesKey(headRefKey)) - if errors.Is(err, stoabs.ErrKeyNotFound) { - log.Logger().Info("Head not stored in metadata, migrating...") - heads := d.headsLegacy(tx) - err = nil // reset error - if len(heads) != 0 { // ignore for empty node - var latestHead hash.SHA256Hash - var latestLC uint32 - - for _, ref := range heads { - transaction, err := getTransaction(ref, tx) - if err != nil { - if errors.Is(err, ErrTransactionNotFound) { - return fmt.Errorf("database migration failed: %w (%s=%s)", err, core.LogFieldTransactionRef, ref) - } - return err - } - if transaction.Clock() >= latestLC { - latestHead = ref - latestLC = transaction.Clock() - } - } - - err = d.setHead(tx, latestHead) - } - } - if err != nil { - return err - } - - return nil - }) -} - func (d *dag) diagnostics(ctx context.Context) []core.DiagnosticResult { var stats Statistics _ = d.db.Read(ctx, func(tx stoabs.ReadTx) error { @@ -296,27 +233,6 @@ func (d dag) getHighestClockValue(tx stoabs.ReadTx) uint32 { return bytesToClock(value) } -// getHighestClockLegacy is used for migration. -// Remove after V5 or V6 release? -func (d dag) getHighestClockLegacy(tx stoabs.ReadTx) uint32 { - reader := tx.GetShelfReader(clockShelf) - var clock uint32 - err := reader.Iterate(func(key stoabs.Key, _ []byte) error { - currentClock := uint32(key.(stoabs.Uint32Key)) - if currentClock > clock { - clock = currentClock - } - return nil - }, stoabs.Uint32Key(0)) - if err != nil { - log.Logger(). - WithError(err). - Error("Failed to read clock shelf") - return 0 - } - return clock -} - func (d dag) getHead(tx stoabs.ReadTx) (hash.SHA256Hash, error) { head, err := tx.GetShelfReader(metadataShelf).Get(stoabs.BytesKey(headRefKey)) if errors.Is(err, stoabs.ErrKeyNotFound) { @@ -329,13 +245,6 @@ func (d dag) getHead(tx stoabs.ReadTx) (hash.SHA256Hash, error) { return hash.FromSlice(head), nil } -// getNumberOfTransactionsLegacy is used for migration. -// Remove after V5 or V6 release? -func (d dag) getNumberOfTransactionsLegacy(tx stoabs.ReadTx) uint64 { - reader := tx.GetShelfReader(transactionsShelf) - return uint64(reader.Stats().NumEntries) -} - func (d dag) setHighestClockValue(tx stoabs.WriteTx, count uint32) error { writer := tx.GetShelfWriter(metadataShelf) bytes := make([]byte, 4) diff --git a/network/dag/dag_test.go b/network/dag/dag_test.go index 935f85691c..4b072621fe 100644 --- a/network/dag/dag_test.go +++ b/network/dag/dag_test.go @@ -107,104 +107,6 @@ func TestDAG_Get(t *testing.T) { }) } -func TestDAG_Migrate(t *testing.T) { - ctx := context.Background() - txRoot := CreateTestTransactionWithJWK(0) - tx1 := CreateTestTransactionWithJWK(1, txRoot) - tx2 := CreateTestTransactionWithJWK(2, tx1) - - t.Run("migrate LC value and transaction count to metadata storage", func(t *testing.T) { - graph := CreateDAG(t) - - // Setup: add transactions, remove metadata - addTx(t, graph, txRoot, tx1, tx2) - err := graph.db.WriteShelf(ctx, metadataShelf, func(writer stoabs.Writer) error { - return writer.Iterate(func(key stoabs.Key, _ []byte) error { - return writer.Delete(key) - }, stoabs.BytesKey{}) - }) - require.NoError(t, err) - - // Check values return 0 - var stats Statistics - var lc uint32 - _ = graph.db.Read(ctx, func(tx stoabs.ReadTx) error { - stats = graph.statistics(tx) - lc = graph.getHighestClockValue(tx) - return nil - }) - assert.Equal(t, uint(0), stats.NumberOfTransactions) - assert.Equal(t, uint32(0), lc) - - // Migrate - err = graph.Migrate() - require.NoError(t, err) - - // Assert - _ = graph.db.Read(ctx, func(tx stoabs.ReadTx) error { - stats = graph.statistics(tx) - lc = graph.getHighestClockValue(tx) - return nil - }) - assert.Equal(t, uint(3), stats.NumberOfTransactions) - assert.Equal(t, tx2.Clock(), lc) - }) - t.Run("migrate head to metadata storage", func(t *testing.T) { - graph := CreateDAG(t) - - // Setup: add transactions, remove metadata, add to headsShelf - addTx(t, graph, txRoot, tx1, tx2) - err := graph.db.WriteShelf(ctx, metadataShelf, func(writer stoabs.Writer) error { - return writer.Iterate(func(key stoabs.Key, _ []byte) error { - return writer.Delete(key) - }, stoabs.BytesKey{}) - }) - require.NoError(t, err) - err = graph.db.WriteShelf(ctx, headsShelf, func(writer stoabs.Writer) error { - _ = writer.Put(stoabs.BytesKey(txRoot.Ref().Slice()), []byte{1}) - _ = writer.Put(stoabs.BytesKey(tx2.Ref().Slice()), []byte{1}) - return writer.Put(stoabs.BytesKey(tx1.Ref().Slice()), []byte{1}) - }) - require.NoError(t, err) - - // Check current head is nil - var head hash.SHA256Hash - _ = graph.db.Read(ctx, func(tx stoabs.ReadTx) error { - head, _ = graph.getHead(tx) - return nil - }) - assert.Equal(t, hash.EmptyHash(), head) - - // Migrate - err = graph.Migrate() - require.NoError(t, err) - - // Assert - _ = graph.db.Read(ctx, func(tx stoabs.ReadTx) error { - head, _ = graph.getHead(tx) - return nil - }) - assert.Equal(t, tx2.Ref(), head) - }) - t.Run("nothing to migrate", func(t *testing.T) { - graph := CreateDAG(t) - addTx(t, graph, txRoot, tx1, tx2) - - err := graph.Migrate() - require.NoError(t, err) - - stats := Statistics{} - var lc uint32 - _ = graph.db.Read(ctx, func(tx stoabs.ReadTx) error { - stats = graph.statistics(tx) - lc = graph.getHighestClockValue(tx) - return nil - }) - assert.Equal(t, uint(3), stats.NumberOfTransactions) - assert.Equal(t, tx2.Clock(), lc) - }) -} - func TestDAG_Add(t *testing.T) { ctx := context.Background() t.Run("ok", func(t *testing.T) { diff --git a/network/dag/mock.go b/network/dag/mock.go index c244959af3..6fceb82067 100644 --- a/network/dag/mock.go +++ b/network/dag/mock.go @@ -24,6 +24,7 @@ import ( type MockState struct { ctrl *gomock.Controller recorder *MockStateMockRecorder + isgomock struct{} } // MockStateMockRecorder is the mock recorder for MockState. @@ -337,6 +338,7 @@ func (mr *MockStateMockRecorder) XOR(reqClock any) *gomock.Call { type MockPayloadStore struct { ctrl *gomock.Controller recorder *MockPayloadStoreMockRecorder + isgomock struct{} } // MockPayloadStoreMockRecorder is the mock recorder for MockPayloadStore. diff --git a/network/dag/notifier.go b/network/dag/notifier.go index 675b3882bf..4b6fa4890a 100644 --- a/network/dag/notifier.go +++ b/network/dag/notifier.go @@ -243,6 +243,7 @@ func (p *notifier) Run() error { } // we're going to retry all events synchronously at startup. For the ones that fail we'll start the retry loop failedAtStartup := make([]Event, 0) + readyToRetry := make([]Event, 0) err := p.db.ReadShelf(p.ctx, p.shelfName(), func(reader stoabs.Reader) error { return reader.Iterate(func(k stoabs.Key, v []byte) error { event := Event{} @@ -253,12 +254,7 @@ func (p *notifier) Run() error { return nil } - if err := p.notifyNow(event); err != nil { - if event.Retries < maxRetries { - failedAtStartup = append(failedAtStartup, event) - } - } - + readyToRetry = append(readyToRetry, event) return nil }, stoabs.BytesKey{}) }) @@ -266,6 +262,15 @@ func (p *notifier) Run() error { return err } + // do outside of main loop to prevent long running read + for _, event := range readyToRetry { + if err := p.notifyNow(event); err != nil { + if event.Retries < maxRetries { + failedAtStartup = append(failedAtStartup, event) + } + } + } + // for all events from failedAtStartup, call retry // this may still produce errors in the logs or even duplicate errors since notifyNow also failed // but rather duplicate errors then errors produced from overloading the DB with transactions diff --git a/network/dag/notifier_mock.go b/network/dag/notifier_mock.go index d21bac735d..d5f9ca5ffa 100644 --- a/network/dag/notifier_mock.go +++ b/network/dag/notifier_mock.go @@ -21,6 +21,7 @@ import ( type MockNotifier struct { ctrl *gomock.Controller recorder *MockNotifierMockRecorder + isgomock struct{} } // MockNotifierMockRecorder is the mock recorder for MockNotifier. diff --git a/network/dag/parser.go b/network/dag/parser.go index 90d5300089..e1be15758f 100644 --- a/network/dag/parser.go +++ b/network/dag/parser.go @@ -97,7 +97,7 @@ func parsePayload(transaction *transaction, _ jws.Headers, message *jws.Message) func parseContentType(transaction *transaction, headers jws.Headers, _ *jws.Message) error { contentType := headers.ContentType() if !ValidatePayloadType(contentType) { - return transactionValidationError(errInvalidPayloadType.Error()) + return transactionValidationError("%w", errInvalidPayloadType) } transaction.payloadType = contentType return nil diff --git a/network/dag/state.go b/network/dag/state.go index 81c2edf519..fe60a74525 100644 --- a/network/dag/state.go +++ b/network/dag/state.go @@ -60,7 +60,7 @@ type state struct { } func (s *state) Migrate() error { - return s.graph.Migrate() + return nil } // NewState returns a new State. The State is used as entry point, it's methods will start transactions and will notify observers from within those transactions. diff --git a/network/interface.go b/network/interface.go index ee49c23094..847d6e58e7 100644 --- a/network/interface.go +++ b/network/interface.go @@ -37,8 +37,6 @@ type Transactions interface { Subscribe(name string, receiver dag.ReceiverFn, filters ...SubscriberOption) error // Subscribers returns the list of notifiers on the DAG that emit events to subscribers. Subscribers() []dag.Notifier - // CleanupSubscriberEvents removes events. Example use is cleaning up events that errored but should be removed due to a bugfix. - CleanupSubscriberEvents(subcriberName, errorPrefix string) error // GetTransactionPayload retrieves the transaction Payload for the given transaction. // If the transaction or Payload is not found, dag.ErrPayloadNotFound is returned. GetTransactionPayload(transactionRef hash.SHA256Hash) ([]byte, error) @@ -61,14 +59,13 @@ type Transactions interface { DiscoverServices(updatedDID did.DID) // AddressBook returns the list of contacts in the address book. AddressBook() []transport.Contact + // Disabled returns true if core.ServerConfig.DIDMethods does not contain 'nuts' + Disabled() bool } // EventType defines a type for specifying the kind of events that can be published/subscribed on the Network. type EventType string -// AnyPayloadType is a wildcard that matches with any payload type. -const AnyPayloadType = "*" - // Receiver defines a callback function for processing transactions/payloads received by the DAG. type Receiver func(transaction dag.Transaction, payload []byte) error diff --git a/network/mock.go b/network/mock.go index f4ae30bfe5..4a2c5c8ecb 100644 --- a/network/mock.go +++ b/network/mock.go @@ -24,6 +24,7 @@ import ( type MockTransactions struct { ctrl *gomock.Controller recorder *MockTransactionsMockRecorder + isgomock struct{} } // MockTransactionsMockRecorder is the mock recorder for MockTransactions. @@ -57,20 +58,6 @@ func (mr *MockTransactionsMockRecorder) AddressBook() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddressBook", reflect.TypeOf((*MockTransactions)(nil).AddressBook)) } -// CleanupSubscriberEvents mocks base method. -func (m *MockTransactions) CleanupSubscriberEvents(subcriberName, errorPrefix string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CleanupSubscriberEvents", subcriberName, errorPrefix) - ret0, _ := ret[0].(error) - return ret0 -} - -// CleanupSubscriberEvents indicates an expected call of CleanupSubscriberEvents. -func (mr *MockTransactionsMockRecorder) CleanupSubscriberEvents(subcriberName, errorPrefix any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupSubscriberEvents", reflect.TypeOf((*MockTransactions)(nil).CleanupSubscriberEvents), subcriberName, errorPrefix) -} - // CreateTransaction mocks base method. func (m *MockTransactions) CreateTransaction(ctx context.Context, spec Template) (dag.Transaction, error) { m.ctrl.T.Helper() @@ -86,6 +73,20 @@ func (mr *MockTransactionsMockRecorder) CreateTransaction(ctx, spec any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTransaction", reflect.TypeOf((*MockTransactions)(nil).CreateTransaction), ctx, spec) } +// Disabled mocks base method. +func (m *MockTransactions) Disabled() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Disabled") + ret0, _ := ret[0].(bool) + return ret0 +} + +// Disabled indicates an expected call of Disabled. +func (mr *MockTransactionsMockRecorder) Disabled() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disabled", reflect.TypeOf((*MockTransactions)(nil).Disabled)) +} + // DiscoverServices mocks base method. func (m *MockTransactions) DiscoverServices(updatedDID did.DID) { m.ctrl.T.Helper() diff --git a/network/network.go b/network/network.go index b573a71345..be8618bdd8 100644 --- a/network/network.go +++ b/network/network.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "net" + "slices" "strings" "sync/atomic" "time" @@ -66,11 +67,15 @@ const ( newNodeConnectionDelay = 5 * time.Minute ) +// ErrDIDNutsDisabled is returned from certain API methods when the core.ServerConfig.DIDMethods does not contain "nuts" +var ErrDIDNutsDisabled = errors.New("network operations not supported; did:nuts support not configured") + // defaultBBoltOptions are given to bbolt, allows for package local adjustments during test var defaultBBoltOptions = bbolt.DefaultOptions // Network implements Transactions interface and Engine functions. type Network struct { + disabled bool // node is running without did:nuts support config Config certificate tls.Certificate trustStore *core.TrustStore @@ -96,6 +101,9 @@ type Network struct { // CheckHealth performs health checks for the network engine. func (n *Network) CheckHealth() map[string]core.Health { + if n.disabled { + return nil + } results := make(map[string]core.Health) if n.certificate.Leaf != nil { results[healthTLS] = n.checkNodeTLSHealth() @@ -134,6 +142,9 @@ func (n *Network) checkNodeTLSHealth() core.Health { } func (n *Network) Migrate() error { + if n.disabled { + return nil + } return n.state.Migrate() } @@ -167,6 +178,10 @@ func NewNetworkInstance( // Configure configures the Network subsystem func (n *Network) Configure(config core.ServerConfig) error { + if !slices.Contains(config.DIDMethods, "nuts") { + n.disabled = true + return nil + } var err error dagStore, err := n.storeProvider.GetKVStore("data", storage.PersistentStorageClass) if err != nil { @@ -269,11 +284,7 @@ func (n *Network) Configure(config core.ServerConfig) error { } else { // Not allowed in strict mode for security reasons: only intended for demo/workshop purposes. if config.Strictmode { - if len(n.config.BootstrapNodes) == 0 && n.assumeNewNode { - log.Logger().Info("It appears the gRPC network will not be used (no bootstrap nodes and an empty network state), so disabled TLS is accepted even with strict mode enabled.") - } else { - return errors.New("disabling TLS in strict mode is not allowed") - } + return errors.New("disabling TLS in strict mode is not allowed") } authenticator = grpc.NewDummyAuthenticator(nil) } @@ -284,6 +295,7 @@ func (n *Network) Configure(config core.ServerConfig) error { return fmt.Errorf("failed to open connections store: %w", err) } + grpcOpts = append(grpcOpts, grpc.WithClientIPHeader(config.HTTP.ClientIPHeaderName)) connectionManCfg, err := grpc.NewConfig(n.config.GrpcAddr, n.peerID, grpcOpts...) if err != nil { return err @@ -313,7 +325,7 @@ func (n *Network) Configure(config core.ServerConfig) error { } func (n *Network) DiscoverServices(updatedDID did.DID) { - if !n.config.EnableDiscovery { + if n.disabled || !n.config.EnableDiscovery { return } document, _, err := n.didStore.Resolve(updatedDID, nil) @@ -363,6 +375,9 @@ func (n *Network) Config() interface{} { // Start initiates the Network subsystem func (n *Network) Start() error { + if n.disabled { + return nil + } startTime := time.Now() n.startTime.Store(&startTime) @@ -571,6 +586,9 @@ func (n *Network) selfTestNutsCommAddress(nutsComm transport.NutsCommURL) error // The receiver is called when a transaction is added to the DAG. // It's only called if the given dag.NotificationFilter's match. func (n *Network) Subscribe(name string, subscriber dag.ReceiverFn, options ...SubscriberOption) error { + if n.disabled { + return nil + } notifierOptions := make([]dag.NotifierOption, len(options)) for i, o := range options { notifierOptions[i] = o() @@ -609,12 +627,18 @@ func (n *Network) CleanupSubscriberEvents(subscriberName, errorPrefix string) er // GetTransaction retrieves the transaction for the given reference. If the transaction is not known, an error is returned. func (n *Network) GetTransaction(transactionRef hash.SHA256Hash) (dag.Transaction, error) { + if n.disabled { + return nil, ErrDIDNutsDisabled + } return n.state.GetTransaction(context.Background(), transactionRef) } // GetTransactionPayload retrieves the transaction Payload for the given transaction. If the transaction or Payload is not found // nil is returned. func (n *Network) GetTransactionPayload(transactionRef hash.SHA256Hash) ([]byte, error) { + if n.disabled { + return nil, ErrDIDNutsDisabled + } transaction, err := n.state.GetTransaction(context.Background(), transactionRef) if err != nil { if errors.Is(err, dag.ErrTransactionNotFound) { @@ -628,11 +652,17 @@ func (n *Network) GetTransactionPayload(transactionRef hash.SHA256Hash) ([]byte, // ListTransactionsInRange returns all transactions known to this Network instance with lamport clock value between startInclusive and endExclusive. func (n *Network) ListTransactionsInRange(startInclusive uint32, endExclusive uint32) ([]dag.Transaction, error) { + if n.disabled { + return nil, ErrDIDNutsDisabled + } return n.state.FindBetweenLC(context.Background(), startInclusive, endExclusive) } // CreateTransaction creates a new transaction from the given template. func (n *Network) CreateTransaction(ctx context.Context, template Template) (dag.Transaction, error) { + if n.disabled { + return nil, ErrDIDNutsDisabled + } payloadHash := hash.SHA256Sum(template.Payload) log.Logger(). WithField(core.LogFieldTransactionType, template.Type). @@ -743,6 +773,9 @@ func (n *Network) calculateLamportClock(ctx context.Context, prevs []hash.SHA256 // Shutdown cleans up any leftover go routines func (n *Network) Shutdown() error { + if n.disabled { + return nil + } // Stop protocols and connection manager for _, prot := range n.protocols { prot.Stop() @@ -758,6 +791,9 @@ func (n *Network) Shutdown() error { // Diagnostics collects and returns diagnostics for the Network engine. func (n *Network) Diagnostics() []core.DiagnosticResult { + if n.disabled { + return nil + } var results = make([]core.DiagnosticResult, 0) // Connection manager and protocols results = append(results, core.DiagnosticResultMap{Title: "connections", Items: n.connectionManager.Diagnostics()}) @@ -778,6 +814,9 @@ func (n *Network) Diagnostics() []core.DiagnosticResult { // PeerDiagnostics returns a map containing diagnostic information of the node's peers. The key contains the remote peer's ID. func (n *Network) PeerDiagnostics() map[transport.PeerID]transport.Diagnostics { + if n.disabled { + return nil + } result := make(map[transport.PeerID]transport.Diagnostics, 0) // We assume higher protocol versions (later in the slice) have better/more accurate diagnostics, // so for now they're copied over diagnostics of earlier versions, unless the entry is empty for that peer. @@ -793,15 +832,25 @@ func (n *Network) PeerDiagnostics() map[transport.PeerID]transport.Diagnostics { } func (n *Network) AddressBook() []transport.Contact { + if n.disabled { + return nil + } return n.connectionManager.Contacts() } +func (n *Network) Disabled() bool { + return n.disabled +} + // ReprocessReport describes the reprocess exection. type ReprocessReport struct { // reserved for future use } func (n *Network) Reprocess(ctx context.Context, contentType string) (*ReprocessReport, error) { + if n.disabled { + return nil, ErrDIDNutsDisabled + } log.Logger().Infof("Starting reprocess of %s", contentType) _, js, err := n.eventPublisher.Pool().Acquire(ctx) diff --git a/network/network_integration_test.go b/network/network_integration_test.go index 593fe6d535..e1d18e557c 100644 --- a/network/network_integration_test.go +++ b/network/network_integration_test.go @@ -901,6 +901,7 @@ func TestNetworkIntegration_TLSOffloading(t *testing.T) { node1 := startNode(t, "node1", testDirectory, func(serverCfg *core.ServerConfig, cfg *Config) { serverCfg.TLS.Offload = core.OffloadIncomingTLS serverCfg.TLS.ClientCertHeaderName = "client-cert" + serverCfg.HTTP.ClientIPHeaderName = "X-Forwarded-For" }) // Create client (node2) that connects to server node diff --git a/network/network_test.go b/network/network_test.go index 9a41c23c54..e07fed42ae 100644 --- a/network/network_test.go +++ b/network/network_test.go @@ -261,14 +261,13 @@ func TestNetwork_Configure(t *testing.T) { assert.EqualError(t, err, "disabling TLS in strict mode is not allowed") }) - t.Run("ok - TLS disabled in strict mode, with empty node", func(t *testing.T) { + t.Run("ok - network disabled if not in supported"+ + " DIDMethods", func(t *testing.T) { ctrl := gomock.NewController(t) ctx := createNetwork(t, ctrl) - ctx.protocol.EXPECT().Configure(gomock.Any()) - ctx.network.connectionManager = nil err := ctx.network.Configure(core.TestServerConfig(func(config *core.ServerConfig) { - config.Datadir = io.TestDirectory(t) + config.DIDMethods = []string{"web"} })) require.NoError(t, err) diff --git a/network/transport/connection_manager_mock.go b/network/transport/connection_manager_mock.go index cbc098971d..8cf63fb483 100644 --- a/network/transport/connection_manager_mock.go +++ b/network/transport/connection_manager_mock.go @@ -22,6 +22,7 @@ import ( type MockConnectionManager struct { ctrl *gomock.Controller recorder *MockConnectionManagerMockRecorder + isgomock struct{} } // MockConnectionManagerMockRecorder is the mock recorder for MockConnectionManager. diff --git a/network/transport/grpc/authenticator_mock.go b/network/transport/grpc/authenticator_mock.go index 1d51801629..c5b9ec9eee 100644 --- a/network/transport/grpc/authenticator_mock.go +++ b/network/transport/grpc/authenticator_mock.go @@ -21,6 +21,7 @@ import ( type MockAuthenticator struct { ctrl *gomock.Controller recorder *MockAuthenticatorMockRecorder + isgomock struct{} } // MockAuthenticatorMockRecorder is the mock recorder for MockAuthenticator. diff --git a/network/transport/grpc/config.go b/network/transport/grpc/config.go index c39aa4f791..9940e37bda 100644 --- a/network/transport/grpc/config.go +++ b/network/transport/grpc/config.go @@ -76,6 +76,14 @@ func WithTLS(clientCertificate tls.Certificate, trustStore *core.TrustStore, pki } } +// WithClientIPHeader sets the HTTP header that is used to extract the client IP. +func WithClientIPHeader(clientIPHeaderName string) ConfigOption { + return func(config *Config) error { + config.clientIPHeaderName = clientIPHeaderName + return nil + } +} + // WithTLSOffloading enables TLS for outgoing connections, but is offloaded for incoming connections. // It MUST be used in conjunction, but after with WithTLS. func WithTLSOffloading(clientCertHeaderName string) ConfigOption { @@ -121,6 +129,8 @@ type Config struct { pkiValidator pki.Validator // clientCertHeaderName specifies the name of the HTTP header that contains the client certificate, if TLS is offloaded. clientCertHeaderName string + // clientIPHeaderName specifies the name of the HTTP header that contains the client IP address. + clientIPHeaderName string // connectionTimeout specifies the time before an outbound connection attempt times out. connectionTimeout time.Duration // listener holds a function to create the net.Listener that is used for inbound connections. diff --git a/network/transport/grpc/connection_list_mock.go b/network/transport/grpc/connection_list_mock.go index 9fa134794f..4280937e38 100644 --- a/network/transport/grpc/connection_list_mock.go +++ b/network/transport/grpc/connection_list_mock.go @@ -19,6 +19,7 @@ import ( type MockConnectionList struct { ctrl *gomock.Controller recorder *MockConnectionListMockRecorder + isgomock struct{} } // MockConnectionListMockRecorder is the mock recorder for MockConnectionList. diff --git a/network/transport/grpc/connection_manager.go b/network/transport/grpc/connection_manager.go index 12371dd029..f738a56fe4 100644 --- a/network/transport/grpc/connection_manager.go +++ b/network/transport/grpc/connection_manager.go @@ -185,7 +185,7 @@ func newGrpcServer(config Config) (*grpc.Server, error) { } // Chain interceptors. ipInterceptor is added last, so it processes the stream first. - serverInterceptors = append(serverInterceptors, ipInterceptor) + serverInterceptors = append(serverInterceptors, ipInterceptor(config.clientIPHeaderName)) serverOpts = append(serverOpts, grpc.ChainStreamInterceptor(serverInterceptors...)) // Create gRPC server for inbound connectionList and associate it with the protocols diff --git a/network/transport/grpc/connection_mock.go b/network/transport/grpc/connection_mock.go index d5cc18ae03..c428827d6f 100644 --- a/network/transport/grpc/connection_mock.go +++ b/network/transport/grpc/connection_mock.go @@ -21,6 +21,7 @@ import ( type MockConnection struct { ctrl *gomock.Controller recorder *MockConnectionMockRecorder + isgomock struct{} } // MockConnectionMockRecorder is the mock recorder for MockConnection. diff --git a/network/transport/grpc/interface_mock.go b/network/transport/grpc/interface_mock.go index ca04f9a756..1c6e951c24 100644 --- a/network/transport/grpc/interface_mock.go +++ b/network/transport/grpc/interface_mock.go @@ -24,6 +24,7 @@ import ( type MockProtocol struct { ctrl *gomock.Controller recorder *MockProtocolMockRecorder + isgomock struct{} } // MockProtocolMockRecorder is the mock recorder for MockProtocol. @@ -226,6 +227,7 @@ func (mr *MockProtocolMockRecorder) Version() *gomock.Call { type MockStream struct { ctrl *gomock.Controller recorder *MockStreamMockRecorder + isgomock struct{} } // MockStreamMockRecorder is the mock recorder for MockStream. @@ -291,6 +293,7 @@ func (mr *MockStreamMockRecorder) SendMsg(m any) *gomock.Call { type MockConn struct { ctrl *gomock.Controller recorder *MockConnMockRecorder + isgomock struct{} } // MockConnMockRecorder is the mock recorder for MockConn. @@ -353,6 +356,7 @@ func (mr *MockConnMockRecorder) NewStream(ctx, desc, method any, opts ...any) *g type MockClientStream struct { ctrl *gomock.Controller recorder *MockClientStreamMockRecorder + isgomock struct{} } // MockClientStreamMockRecorder is the mock recorder for MockClientStream. diff --git a/network/transport/grpc/ip_interceptor.go b/network/transport/grpc/ip_interceptor.go index 14f0e11157..992d8c1f17 100644 --- a/network/transport/grpc/ip_interceptor.go +++ b/network/transport/grpc/ip_interceptor.go @@ -32,22 +32,35 @@ const headerXForwardedFor = "X-Forwarded-For" // ipInterceptor tries to extract the IP from the X-Forwarded-For header and sets this as the peers address. // No address is set if the header is unavailable. -func ipInterceptor(srv interface{}, serverStream grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - addr := addrFrom(extractIPFromXFFHeader(serverStream)) - if addr == nil { - // Exit without change if there is no X-Forwarded-For in the http header, - // or if no IP could be extracted from the header. - // This will default to the IP found in lvl 4 header. - return handler(srv, serverStream) +func ipInterceptor(ipHeader string) grpc.StreamServerInterceptor { + var extractIPHeader func(serverStream grpc.ServerStream) string + switch ipHeader { + case headerXForwardedFor: + extractIPHeader = extractIPFromXFFHeader + case "": + extractIPHeader = func(_ grpc.ServerStream) string { + return "" + } + default: + extractIPHeader = extractIPFromCustomHeader(ipHeader) } + return func(srv interface{}, serverStream grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + addr := addrFrom(extractIPHeader(serverStream)) + if addr == nil { + // Exit without change if there is no X-Forwarded-For in the http header, + // or if no IP could be extracted from the header. + // This will default to the IP found in lvl 4 header. + return handler(srv, serverStream) + } - peerInfo, _ := peer.FromContext(serverStream.Context()) - if peerInfo == nil { - peerInfo = &peer.Peer{} + peerInfo, _ := peer.FromContext(serverStream.Context()) + if peerInfo == nil { + peerInfo = &peer.Peer{} + } + peerInfo.Addr = addr + ctx := peer.NewContext(serverStream.Context(), peerInfo) + return handler(srv, &wrappedServerStream{ctx: ctx, ServerStream: serverStream}) } - peerInfo.Addr = addr - ctx := peer.NewContext(serverStream.Context(), peerInfo) - return handler(srv, &wrappedServerStream{ctx: ctx, ServerStream: serverStream}) } // extractIPFromXFFHeader tries to retrieve the address from X-Forward-For header. Returns an empty string if non found. @@ -64,7 +77,7 @@ func extractIPFromXFFHeader(serverStream grpc.ServerStream) string { } ips := strings.Split(strings.Join(xffs, ","), ",") for i := len(ips) - 1; i >= 0; i-- { - ip := net.ParseIP(strings.TrimSpace(ips[i])) + ip := net.ParseIP(trimIP(ips[i])) if ip == nil { // Unable to parse IP; cannot trust entire records return ipUnknown @@ -97,3 +110,28 @@ func addrFrom(ip string) net.Addr { func isInternal(ip net.IP) bool { return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() } + +// extractIPFromCustomHeader extracts an IP address from any custom header. +// This is an altered version of echo.ExtractIPFromRealIPHeader() that does not check for trusted IPs. +func extractIPFromCustomHeader(ipHeader string) func(serverStream grpc.ServerStream) string { + return func(serverStream grpc.ServerStream) string { + ipUnknown := "" + md, ok := metadata.FromIncomingContext(serverStream.Context()) + if !ok { + return ipUnknown + } + header := md.Get(ipHeader) + if len(header) != 1 { + return ipUnknown + } + return trimIP(header[0]) + } +} + +func trimIP(ip string) string { + ip = strings.TrimSpace(ip) + // trim brackets from IPv6 + ip = strings.TrimPrefix(ip, "[") + ip = strings.TrimSuffix(ip, "]") + return ip +} diff --git a/network/transport/grpc/ip_interceptor_test.go b/network/transport/grpc/ip_interceptor_test.go index a16a3ca21f..8e6f4a36c3 100644 --- a/network/transport/grpc/ip_interceptor_test.go +++ b/network/transport/grpc/ip_interceptor_test.go @@ -61,6 +61,7 @@ func Test_ipInterceptor(t *testing.T) { IP: ipAddr.AsSlice(), Zone: ipAddr.Zone(), } + ipv6 = "[" + ipv6 + "]" // trims brackets from ipv6 internalXFFAddr, _ := net.ResolveIPAddr("ip", internalIPs[0]) peerNoAddres := net.Addr(nil) @@ -79,12 +80,15 @@ func Test_ipInterceptor(t *testing.T) { } for _, tc := range tests { + ran := false serverStream.ctx = contextWithMD(strings.Join(tc.xffIPs, ",")) - _ = ipInterceptor(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { + _ = ipInterceptor(headerXForwardedFor)(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { peerInfo, success = peer.FromContext(wrappedStream.Context()) + ran = true return nil }) + require.True(t, ran, "test logic was not executed") if success { assert.Equal(t, tc.expected.String(), peerInfo.Addr.String()) } else { @@ -93,21 +97,73 @@ func Test_ipInterceptor(t *testing.T) { } }) t.Run("no XXF header", func(t *testing.T) { + ran := false serverStream.ctx = metadata.NewIncomingContext(context.Background(), metadata.New(map[string]string{})) - _ = ipInterceptor(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { + _ = ipInterceptor(headerXForwardedFor)(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { peerInfo, success = peer.FromContext(wrappedStream.Context()) + ran = true return nil }) + require.True(t, ran, "test logic was not executed") assert.False(t, success) assert.Nil(t, peerInfo) }) t.Run("no metadata in context", func(t *testing.T) { + ran := false serverStream.ctx = context.Background() - _ = ipInterceptor(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { + _ = ipInterceptor(headerXForwardedFor)(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { peerInfo, success = peer.FromContext(wrappedStream.Context()) + ran = true return nil }) + require.True(t, ran, "test logic was not executed") assert.False(t, success) assert.Nil(t, peerInfo) }) + t.Run("custom header", func(t *testing.T) { + header := "X-Custom-Header" + expectedIP := "1.2.3.4" + t.Run("ok", func(t *testing.T) { + ran := false + md := metadata.New(map[string]string{header: expectedIP}) + serverStream.ctx = metadata.NewIncomingContext(context.Background(), md) + _ = ipInterceptor(header)(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { + peerInfo, success = peer.FromContext(wrappedStream.Context()) + ran = true + return nil + }) + + require.True(t, ran, "test logic was not executed") + assert.True(t, success) + assert.Equal(t, expectedIP, peerInfo.Addr.String()) + }) + t.Run("empty header", func(t *testing.T) { + ran := false + md := metadata.New(map[string]string{header: ""}) + serverStream.ctx = metadata.NewIncomingContext(context.Background(), md) + _ = ipInterceptor(header)(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { + peerInfo, success = peer.FromContext(wrappedStream.Context()) + ran = true + return nil + }) + + require.True(t, ran, "test logic was not executed") + assert.False(t, success) + assert.Nil(t, peerInfo) + }) + t.Run("invalid input on custom header", func(t *testing.T) { + ran := false + md := metadata.New(map[string]string{header: strings.Join([]string{expectedIP, expectedIP}, ",")}) + serverStream.ctx = metadata.NewIncomingContext(context.Background(), md) + _ = ipInterceptor(header)(nil, serverStream, nil, func(srv interface{}, wrappedStream grpc.ServerStream) error { + peerInfo, success = peer.FromContext(wrappedStream.Context()) + ran = true + return nil + }) + + require.True(t, ran, "test logic was not executed") + assert.False(t, success) + assert.Nil(t, peerInfo) + }) + }) } diff --git a/network/transport/grpc/testprotocol.pb.go b/network/transport/grpc/testprotocol.pb.go index 263a659ec8..8ae4e6dfe8 100644 --- a/network/transport/grpc/testprotocol.pb.go +++ b/network/transport/grpc/testprotocol.pb.go @@ -17,8 +17,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.34.1 -// protoc v4.25.3 +// protoc-gen-go v1.34.2 +// protoc v5.28.2 // source: transport/grpc/testprotocol.proto package grpc @@ -116,7 +116,7 @@ func file_transport_grpc_testprotocol_proto_rawDescGZIP() []byte { } var file_transport_grpc_testprotocol_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_transport_grpc_testprotocol_proto_goTypes = []interface{}{ +var file_transport_grpc_testprotocol_proto_goTypes = []any{ (*TestMessage)(nil), // 0: grpc.TestMessage } var file_transport_grpc_testprotocol_proto_depIdxs = []int32{ @@ -135,7 +135,7 @@ func file_transport_grpc_testprotocol_proto_init() { return } if !protoimpl.UnsafeEnabled { - file_transport_grpc_testprotocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_transport_grpc_testprotocol_proto_msgTypes[0].Exporter = func(v any, i int) any { switch v := v.(*TestMessage); i { case 0: return &v.state diff --git a/network/transport/grpc/testprotocol_grpc.pb.go b/network/transport/grpc/testprotocol_grpc.pb.go index b208b01b2d..592d7ea811 100644 --- a/network/transport/grpc/testprotocol_grpc.pb.go +++ b/network/transport/grpc/testprotocol_grpc.pb.go @@ -17,8 +17,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.3 +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.2 // source: transport/grpc/testprotocol.proto package grpc @@ -32,8 +32,8 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 const ( Test_DoStuff_FullMethodName = "/grpc.Test/DoStuff" @@ -43,7 +43,7 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type TestClient interface { - DoStuff(ctx context.Context, opts ...grpc.CallOption) (Test_DoStuffClient, error) + DoStuff(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TestMessage, TestMessage], error) } type testClient struct { @@ -54,51 +54,37 @@ func NewTestClient(cc grpc.ClientConnInterface) TestClient { return &testClient{cc} } -func (c *testClient) DoStuff(ctx context.Context, opts ...grpc.CallOption) (Test_DoStuffClient, error) { - stream, err := c.cc.NewStream(ctx, &Test_ServiceDesc.Streams[0], Test_DoStuff_FullMethodName, opts...) +func (c *testClient) DoStuff(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TestMessage, TestMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Test_ServiceDesc.Streams[0], Test_DoStuff_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &testDoStuffClient{stream} + x := &grpc.GenericClientStream[TestMessage, TestMessage]{ClientStream: stream} return x, nil } -type Test_DoStuffClient interface { - Send(*TestMessage) error - Recv() (*TestMessage, error) - grpc.ClientStream -} - -type testDoStuffClient struct { - grpc.ClientStream -} - -func (x *testDoStuffClient) Send(m *TestMessage) error { - return x.ClientStream.SendMsg(m) -} - -func (x *testDoStuffClient) Recv() (*TestMessage, error) { - m := new(TestMessage) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Test_DoStuffClient = grpc.BidiStreamingClient[TestMessage, TestMessage] // TestServer is the server API for Test service. // All implementations should embed UnimplementedTestServer -// for forward compatibility +// for forward compatibility. type TestServer interface { - DoStuff(Test_DoStuffServer) error + DoStuff(grpc.BidiStreamingServer[TestMessage, TestMessage]) error } -// UnimplementedTestServer should be embedded to have forward compatible implementations. -type UnimplementedTestServer struct { -} +// UnimplementedTestServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTestServer struct{} -func (UnimplementedTestServer) DoStuff(Test_DoStuffServer) error { +func (UnimplementedTestServer) DoStuff(grpc.BidiStreamingServer[TestMessage, TestMessage]) error { return status.Errorf(codes.Unimplemented, "method DoStuff not implemented") } +func (UnimplementedTestServer) testEmbeddedByValue() {} // UnsafeTestServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to TestServer will @@ -108,34 +94,22 @@ type UnsafeTestServer interface { } func RegisterTestServer(s grpc.ServiceRegistrar, srv TestServer) { + // If the following call pancis, it indicates UnimplementedTestServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&Test_ServiceDesc, srv) } func _Test_DoStuff_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(TestServer).DoStuff(&testDoStuffServer{stream}) -} - -type Test_DoStuffServer interface { - Send(*TestMessage) error - Recv() (*TestMessage, error) - grpc.ServerStream -} - -type testDoStuffServer struct { - grpc.ServerStream + return srv.(TestServer).DoStuff(&grpc.GenericServerStream[TestMessage, TestMessage]{ServerStream: stream}) } -func (x *testDoStuffServer) Send(m *TestMessage) error { - return x.ServerStream.SendMsg(m) -} - -func (x *testDoStuffServer) Recv() (*TestMessage, error) { - m := new(TestMessage) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Test_DoStuffServer = grpc.BidiStreamingServer[TestMessage, TestMessage] // Test_ServiceDesc is the grpc.ServiceDesc for Test service. // It's only intended for direct use with grpc.RegisterService, diff --git a/network/transport/protocol_mock.go b/network/transport/protocol_mock.go index bda7b5f01d..9748f8fd82 100644 --- a/network/transport/protocol_mock.go +++ b/network/transport/protocol_mock.go @@ -20,6 +20,7 @@ import ( type MockProtocol struct { ctrl *gomock.Controller recorder *MockProtocolMockRecorder + isgomock struct{} } // MockProtocolMockRecorder is the mock recorder for MockProtocol. diff --git a/network/transport/v2/gossip/mock.go b/network/transport/v2/gossip/mock.go index 3b3c91818a..c78fdd998f 100644 --- a/network/transport/v2/gossip/mock.go +++ b/network/transport/v2/gossip/mock.go @@ -21,6 +21,7 @@ import ( type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. diff --git a/network/transport/v2/protocol.pb.go b/network/transport/v2/protocol.pb.go index 5af2f30ef2..f3829073ff 100644 --- a/network/transport/v2/protocol.pb.go +++ b/network/transport/v2/protocol.pb.go @@ -17,8 +17,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.34.1 -// protoc v4.25.3 +// protoc-gen-go v1.34.2 +// protoc v5.28.2 // source: transport/v2/protocol.proto package v2 @@ -1051,7 +1051,7 @@ func file_transport_v2_protocol_proto_rawDescGZIP() []byte { } var file_transport_v2_protocol_proto_msgTypes = make([]protoimpl.MessageInfo, 11) -var file_transport_v2_protocol_proto_goTypes = []interface{}{ +var file_transport_v2_protocol_proto_goTypes = []any{ (*Envelope)(nil), // 0: v2.Envelope (*Transaction)(nil), // 1: v2.Transaction (*Gossip)(nil), // 2: v2.Gossip @@ -1090,7 +1090,7 @@ func file_transport_v2_protocol_proto_init() { return } if !protoimpl.UnsafeEnabled { - file_transport_v2_protocol_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[0].Exporter = func(v any, i int) any { switch v := v.(*Envelope); i { case 0: return &v.state @@ -1102,7 +1102,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[1].Exporter = func(v any, i int) any { switch v := v.(*Transaction); i { case 0: return &v.state @@ -1114,7 +1114,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[2].Exporter = func(v any, i int) any { switch v := v.(*Gossip); i { case 0: return &v.state @@ -1126,7 +1126,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[3].Exporter = func(v any, i int) any { switch v := v.(*State); i { case 0: return &v.state @@ -1138,7 +1138,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[4].Exporter = func(v any, i int) any { switch v := v.(*TransactionSet); i { case 0: return &v.state @@ -1150,7 +1150,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[5].Exporter = func(v any, i int) any { switch v := v.(*TransactionListQuery); i { case 0: return &v.state @@ -1162,7 +1162,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[6].Exporter = func(v any, i int) any { switch v := v.(*TransactionRangeQuery); i { case 0: return &v.state @@ -1174,7 +1174,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[7].Exporter = func(v any, i int) any { switch v := v.(*TransactionList); i { case 0: return &v.state @@ -1186,7 +1186,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[8].Exporter = func(v any, i int) any { switch v := v.(*TransactionPayloadQuery); i { case 0: return &v.state @@ -1198,7 +1198,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[9].Exporter = func(v any, i int) any { switch v := v.(*TransactionPayload); i { case 0: return &v.state @@ -1210,7 +1210,7 @@ func file_transport_v2_protocol_proto_init() { return nil } } - file_transport_v2_protocol_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + file_transport_v2_protocol_proto_msgTypes[10].Exporter = func(v any, i int) any { switch v := v.(*Diagnostics); i { case 0: return &v.state @@ -1223,7 +1223,7 @@ func file_transport_v2_protocol_proto_init() { } } } - file_transport_v2_protocol_proto_msgTypes[0].OneofWrappers = []interface{}{ + file_transport_v2_protocol_proto_msgTypes[0].OneofWrappers = []any{ (*Envelope_Gossip)(nil), (*Envelope_DiagnosticsBroadcast)(nil), (*Envelope_State)(nil), @@ -1234,7 +1234,7 @@ func file_transport_v2_protocol_proto_init() { (*Envelope_TransactionList)(nil), (*Envelope_TransactionPayload)(nil), } - file_transport_v2_protocol_proto_msgTypes[1].OneofWrappers = []interface{}{} + file_transport_v2_protocol_proto_msgTypes[1].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/network/transport/v2/protocol_grpc.pb.go b/network/transport/v2/protocol_grpc.pb.go index 24eb0383b0..1fa26ffcf7 100644 --- a/network/transport/v2/protocol_grpc.pb.go +++ b/network/transport/v2/protocol_grpc.pb.go @@ -17,8 +17,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.3 +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.2 // source: transport/v2/protocol.proto package v2 @@ -32,8 +32,8 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 const ( Protocol_Stream_FullMethodName = "/v2.Protocol/Stream" @@ -43,7 +43,7 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ProtocolClient interface { - Stream(ctx context.Context, opts ...grpc.CallOption) (Protocol_StreamClient, error) + Stream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Envelope, Envelope], error) } type protocolClient struct { @@ -54,51 +54,37 @@ func NewProtocolClient(cc grpc.ClientConnInterface) ProtocolClient { return &protocolClient{cc} } -func (c *protocolClient) Stream(ctx context.Context, opts ...grpc.CallOption) (Protocol_StreamClient, error) { - stream, err := c.cc.NewStream(ctx, &Protocol_ServiceDesc.Streams[0], Protocol_Stream_FullMethodName, opts...) +func (c *protocolClient) Stream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Envelope, Envelope], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Protocol_ServiceDesc.Streams[0], Protocol_Stream_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &protocolStreamClient{stream} + x := &grpc.GenericClientStream[Envelope, Envelope]{ClientStream: stream} return x, nil } -type Protocol_StreamClient interface { - Send(*Envelope) error - Recv() (*Envelope, error) - grpc.ClientStream -} - -type protocolStreamClient struct { - grpc.ClientStream -} - -func (x *protocolStreamClient) Send(m *Envelope) error { - return x.ClientStream.SendMsg(m) -} - -func (x *protocolStreamClient) Recv() (*Envelope, error) { - m := new(Envelope) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Protocol_StreamClient = grpc.BidiStreamingClient[Envelope, Envelope] // ProtocolServer is the server API for Protocol service. // All implementations should embed UnimplementedProtocolServer -// for forward compatibility +// for forward compatibility. type ProtocolServer interface { - Stream(Protocol_StreamServer) error + Stream(grpc.BidiStreamingServer[Envelope, Envelope]) error } -// UnimplementedProtocolServer should be embedded to have forward compatible implementations. -type UnimplementedProtocolServer struct { -} +// UnimplementedProtocolServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedProtocolServer struct{} -func (UnimplementedProtocolServer) Stream(Protocol_StreamServer) error { +func (UnimplementedProtocolServer) Stream(grpc.BidiStreamingServer[Envelope, Envelope]) error { return status.Errorf(codes.Unimplemented, "method Stream not implemented") } +func (UnimplementedProtocolServer) testEmbeddedByValue() {} // UnsafeProtocolServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to ProtocolServer will @@ -108,34 +94,22 @@ type UnsafeProtocolServer interface { } func RegisterProtocolServer(s grpc.ServiceRegistrar, srv ProtocolServer) { + // If the following call pancis, it indicates UnimplementedProtocolServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&Protocol_ServiceDesc, srv) } func _Protocol_Stream_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(ProtocolServer).Stream(&protocolStreamServer{stream}) -} - -type Protocol_StreamServer interface { - Send(*Envelope) error - Recv() (*Envelope, error) - grpc.ServerStream -} - -type protocolStreamServer struct { - grpc.ServerStream + return srv.(ProtocolServer).Stream(&grpc.GenericServerStream[Envelope, Envelope]{ServerStream: stream}) } -func (x *protocolStreamServer) Send(m *Envelope) error { - return x.ServerStream.SendMsg(m) -} - -func (x *protocolStreamServer) Recv() (*Envelope, error) { - m := new(Envelope) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Protocol_StreamServer = grpc.BidiStreamingServer[Envelope, Envelope] // Protocol_ServiceDesc is the grpc.ServiceDesc for Protocol service. // It's only intended for direct use with grpc.RegisterService, diff --git a/network/transport/v2/senders_mock.go b/network/transport/v2/senders_mock.go index fbd0177e86..5c2ad466d5 100644 --- a/network/transport/v2/senders_mock.go +++ b/network/transport/v2/senders_mock.go @@ -23,6 +23,7 @@ import ( type MockmessageSender struct { ctrl *gomock.Controller recorder *MockmessageSenderMockRecorder + isgomock struct{} } // MockmessageSenderMockRecorder is the mock recorder for MockmessageSender. diff --git a/pki/denylist.go b/pki/denylist.go index 1f836b9a0e..e1a3039fb1 100644 --- a/pki/denylist.go +++ b/pki/denylist.go @@ -219,6 +219,7 @@ func (b *denylistImpl) Subscribe(f func()) { // download retrieves and parses the denylist func (b *denylistImpl) download() ([]byte, error) { // Make an HTTP GET request for the denylist URL + // We do not use our safe http client here since we're downloading from our own resource httpClient := http.Client{Timeout: syncTimeout} response, err := httpClient.Get(b.url) if err != nil { diff --git a/pki/interface.go b/pki/interface.go index 95fd2c026b..8b36313095 100644 --- a/pki/interface.go +++ b/pki/interface.go @@ -63,9 +63,13 @@ type Validator interface { // ErrCRLMissing and ErrCRLExpired signal that at least one of the certificates cannot be validated reliably. // If the certificate was revoked on an expired CRL, it wil return ErrCertRevoked. // Ignoring all errors except ErrCertRevoked changes the behavior from hard-fail to soft-fail. Without a truststore, the Validator is a noop if set to soft-fail + // Validate uses the configured soft-/hard-fail strategy // The certificate chain is expected to be sorted leaf to root. Validate(chain []*x509.Certificate) error + // ValidateStrict does the same as Validate, except it always uses the hard-fail strategy. + ValidateStrict(chain []*x509.Certificate) error + // SetVerifyPeerCertificateFunc sets config.ValidatePeerCertificate to use Validate. SetVerifyPeerCertificateFunc(config *tls.Config) error diff --git a/pki/mock.go b/pki/mock.go index 293a7a1fd7..21d8c97a3e 100644 --- a/pki/mock.go +++ b/pki/mock.go @@ -23,6 +23,7 @@ import ( type MockDenylist struct { ctrl *gomock.Controller recorder *MockDenylistMockRecorder + isgomock struct{} } // MockDenylistMockRecorder is the mock recorder for MockDenylist. @@ -114,6 +115,7 @@ func (mr *MockDenylistMockRecorder) ValidateCert(cert any) *gomock.Call { type MockValidator struct { ctrl *gomock.Controller recorder *MockValidatorMockRecorder + isgomock struct{} } // MockValidatorMockRecorder is the mock recorder for MockValidator. @@ -187,10 +189,25 @@ func (mr *MockValidatorMockRecorder) Validate(chain any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockValidator)(nil).Validate), chain) } +// ValidateStrict mocks base method. +func (m *MockValidator) ValidateStrict(chain []*x509.Certificate) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateStrict", chain) + ret0, _ := ret[0].(error) + return ret0 +} + +// ValidateStrict indicates an expected call of ValidateStrict. +func (mr *MockValidatorMockRecorder) ValidateStrict(chain any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateStrict", reflect.TypeOf((*MockValidator)(nil).ValidateStrict), chain) +} + // MockProvider is a mock of Provider interface. type MockProvider struct { ctrl *gomock.Controller recorder *MockProviderMockRecorder + isgomock struct{} } // MockProviderMockRecorder is the mock recorder for MockProvider. @@ -278,3 +295,17 @@ func (mr *MockProviderMockRecorder) Validate(chain any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockProvider)(nil).Validate), chain) } + +// ValidateStrict mocks base method. +func (m *MockProvider) ValidateStrict(chain []*x509.Certificate) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateStrict", chain) + ret0, _ := ret[0].(error) + return ret0 +} + +// ValidateStrict indicates an expected call of ValidateStrict. +func (mr *MockProviderMockRecorder) ValidateStrict(chain any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateStrict", reflect.TypeOf((*MockProvider)(nil).ValidateStrict), chain) +} diff --git a/pki/validator.go b/pki/validator.go index 3043668a3d..ad1a9da3fe 100644 --- a/pki/validator.go +++ b/pki/validator.go @@ -88,6 +88,7 @@ func newRevocationList(cert *x509.Certificate) *revocationList { // newValidator returns a new PKI (crl/denylist) validator. func newValidator(config Config) (*validator, error) { + // we do not use our safe http client here since we're downloading from a trusted resource return newValidatorWithHTTPClient(config, &http.Client{Timeout: syncTimeout}) } @@ -127,6 +128,14 @@ func (v *validator) syncLoop(ctx context.Context) { } func (v *validator) Validate(chain []*x509.Certificate) error { + return v.validate(chain, v.softfail) +} + +func (v *validator) ValidateStrict(chain []*x509.Certificate) error { + return v.validate(chain, false) +} + +func (v *validator) validate(chain []*x509.Certificate, softfail bool) error { var cert *x509.Certificate var err error for i := range chain { @@ -134,7 +143,7 @@ func (v *validator) Validate(chain []*x509.Certificate) error { // check in reverse order to prevent CRL expiration errors due to revoked CAs no longer issuing CRLs if err = v.validateCert(cert); err != nil { errOut := fmt.Errorf("%w: subject=%s, S/N=%s, issuer=%s", err, cert.Subject.String(), cert.SerialNumber.String(), cert.Issuer.String()) - if v.softfail && !(errors.Is(err, ErrCertRevoked) || errors.Is(err, ErrCertBanned)) { + if softfail && !(errors.Is(err, ErrCertRevoked) || errors.Is(err, ErrCertBanned)) { // Accept the certificate even if it cannot be properly validated logger().WithError(errOut).Error("Certificate CRL check softfail bypass. Might be unsafe, find cause of failure!") continue diff --git a/pki/validator_test.go b/pki/validator_test.go index 081275afd1..2e8e1f6bf2 100644 --- a/pki/validator_test.go +++ b/pki/validator_test.go @@ -110,11 +110,21 @@ func TestValidator_Validate(t *testing.T) { assert.ErrorIs(t, err, expected) } } + fnStrict := func(expected error) { + val.softfail = true // make sure it ignores the configured value + err = val.ValidateStrict([]*x509.Certificate{cert}) + if expected == nil { + assert.NoError(t, err) + } else { + assert.ErrorIs(t, err, expected) + } + } t.Run("softfail", func(t *testing.T) { fn(true, softfailReturn) }) t.Run("hardfail", func(t *testing.T) { fn(false, hardfailReturn) + fnStrict(hardfailReturn) }) } diff --git a/policy/local.go b/policy/local.go index 0b88facf57..81719eb7d8 100644 --- a/policy/local.go +++ b/policy/local.go @@ -41,8 +41,7 @@ func New() *LocalPDP { // It loads a file with the mapping from oauth scope to PEX Policy. // It allows access when the requester can present a submission according to the Presentation Definition. type LocalPDP struct { - backend PDPBackend - config Config + config Config // mapping holds the oauth scope to PEX Policy mapping mapping map[string]validatingWalletOwnerMapping } @@ -52,7 +51,6 @@ func (b *LocalPDP) Name() string { } func (b *LocalPDP) Configure(_ core.ServerConfig) error { - // check if directory exists if b.config.Directory != "" { _, err := os.Stat(b.config.Directory) if err != nil { diff --git a/policy/mock.go b/policy/mock.go index d39675a562..a406fb20cf 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -21,6 +21,7 @@ import ( type MockPDPBackend struct { ctrl *gomock.Controller recorder *MockPDPBackendMockRecorder + isgomock struct{} } // MockPDPBackendMockRecorder is the mock recorder for MockPDPBackend. diff --git a/storage/engine.go b/storage/engine.go index 9b70099b27..6826d0a3be 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -24,16 +24,17 @@ import ( "fmt" "os" "path" + "path/filepath" "strings" "sync" "time" - "github.com/glebarez/sqlite" _ "github.com/microsoft/go-mssqldb/azuread" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage/log" "github.com/nuts-foundation/nuts-node/storage/sql_migrations" + "github.com/nuts-foundation/sqlite" "github.com/pressly/goose/v3" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" @@ -41,6 +42,7 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlserver" "gorm.io/gorm" + _ "modernc.org/sqlite" ) const storeShutdownTimeout = 5 * time.Second @@ -147,6 +149,12 @@ func (e *engine) Shutdown() error { func (e *engine) Configure(config core.ServerConfig) error { e.datadir = config.Datadir + err := confirmWriteAccess(e.datadir) + if err != nil { + return err + } + + // KV-storage if e.config.Redis.isConfigured() { redisDB, err := createRedisDatabase(e.config.Redis) if err != nil { @@ -163,10 +171,12 @@ func (e *engine) Configure(config core.ServerConfig) error { } e.databases = append(e.databases, bboltDB) - if err := e.initSQLDatabase(); err != nil { + // SQL storage + if err := e.initSQLDatabase(config.Strictmode); err != nil { return fmt.Errorf("failed to initialize SQL database: %w", err) } + // session storage redisConfig := e.config.Session.Redis if redisConfig.isConfigured() { redisDB, err := createRedisDatabase(redisConfig) @@ -202,9 +212,13 @@ func (e *engine) GetSQLDatabase() *gorm.DB { // initSQLDatabase initializes the SQL database connection. // If the connection string is not configured, it defaults to a SQLite database, stored in the node's data directory. -func (e *engine) initSQLDatabase() error { +func (e *engine) initSQLDatabase(strictmode bool) error { connectionString := e.config.SQL.ConnectionString if len(connectionString) == 0 { + if strictmode { + return errors.New("no database configured: storage.sql.connection must be set in strictmode") + } + // non-strictmode uses SQLite as default connectionString = sqliteConnectionString(e.datadir) } @@ -246,8 +260,9 @@ func (e *engine) initSQLDatabase() error { // With 1 connection, all actions will be performed sequentially. This impacts performance, but SQLite should not be used in production. // See https://github.com/nuts-foundation/nuts-node/pull/2589#discussion_r1399130608 db.SetMaxOpenConns(1) - dialector := sqlite.Dialector{Conn: db} - e.sqlDB, err = gorm.Open(dialector, gormConfig) + e.sqlDB, err = gorm.Open(sqlite.Dialector{ + Conn: db, + }, gormConfig) if err != nil { return err } @@ -367,6 +382,33 @@ func (p *provider) getStore(moduleName string, name string, adapter database) (s return store, err } +func confirmWriteAccess(datadir string) error { + // Make sure the data directory exists + err := os.MkdirAll(path.Dir(datadir+string(os.PathSeparator)), os.ModePerm) + if err != nil { + // log error: "unable to create datadir (dir=./data): mkdir ./data: read-only file system" + return err + } + filename := filepath.Join(datadir, "rw-access-test-file") + // open/create file with read-write permission + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + // log error: "unable to configure Storage: open data/rw-access-test-file: read-only file system" + return err + } + // cleanup + err = file.Close() + if err != nil { + return err + } + // removing the file could cause issues if it was a pre-existing user file + err = os.Remove(filename) + if err != nil { + return err + } + return nil +} + type logrusInfoLogWriter struct { } diff --git a/storage/engine_test.go b/storage/engine_test.go index 121a21367a..0e6b68aff3 100644 --- a/storage/engine_test.go +++ b/storage/engine_test.go @@ -119,9 +119,16 @@ func Test_engine_sqlDatabase(t *testing.T) { dataDir := io.TestDirectory(t) require.NoError(t, os.Remove(dataDir)) e := New() - err := e.Configure(core.ServerConfig{Datadir: dataDir}) + e.(*engine).datadir = dataDir + err := e.(*engine).initSQLDatabase(false) assert.ErrorContains(t, err, "unable to open database file") }) + t.Run("no DB configured in strictmode", func(t *testing.T) { + e := New() + e.(*engine).datadir = io.TestDirectory(t) + err := e.(*engine).initSQLDatabase(true) + assert.ErrorContains(t, err, "no database configured: storage.sql.connection must be set in strictmode") + }) t.Run("sqlite is restricted to 1 connection", func(t *testing.T) { e := New() require.NoError(t, e.Configure(core.ServerConfig{Datadir: t.TempDir()})) diff --git a/storage/mock.go b/storage/mock.go index 5beb253a22..3cf42b60ad 100644 --- a/storage/mock.go +++ b/storage/mock.go @@ -23,6 +23,7 @@ import ( type MockEngine struct { ctrl *gomock.Controller recorder *MockEngineMockRecorder + isgomock struct{} } // MockEngineMockRecorder is the mock recorder for MockEngine. @@ -130,6 +131,7 @@ func (mr *MockEngineMockRecorder) Start() *gomock.Call { type MockProvider struct { ctrl *gomock.Controller recorder *MockProviderMockRecorder + isgomock struct{} } // MockProviderMockRecorder is the mock recorder for MockProvider. @@ -168,6 +170,7 @@ func (mr *MockProviderMockRecorder) GetKVStore(name, class any) *gomock.Call { type Mockdatabase struct { ctrl *gomock.Controller recorder *MockdatabaseMockRecorder + isgomock struct{} } // MockdatabaseMockRecorder is the mock recorder for Mockdatabase. @@ -232,6 +235,7 @@ func (mr *MockdatabaseMockRecorder) getClass() *gomock.Call { type MockSessionDatabase struct { ctrl *gomock.Controller recorder *MockSessionDatabaseMockRecorder + isgomock struct{} } // MockSessionDatabaseMockRecorder is the mock recorder for MockSessionDatabase. @@ -286,6 +290,7 @@ func (mr *MockSessionDatabaseMockRecorder) close() *gomock.Call { type MockSessionStore struct { ctrl *gomock.Controller recorder *MockSessionStoreMockRecorder + isgomock struct{} } // MockSessionStoreMockRecorder is the mock recorder for MockSessionStore. diff --git a/storage/orm/did_document.go b/storage/orm/did_document.go index f0d6a05a2d..d0def02736 100644 --- a/storage/orm/did_document.go +++ b/storage/orm/did_document.go @@ -20,6 +20,9 @@ package orm import ( "encoding/json" + "time" + + "github.com/google/uuid" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/jsonld" "gorm.io/gorm/schema" @@ -102,3 +105,65 @@ func (sqlDoc DidDocument) GenerateDIDDocument() (did.Document, error) { return document, nil } + +// MigrationDocument is used to convert a did.Document + metadata to a DidDocument. +// DEPRECATED: only intended to migrate owned did:nuts to SQL storage. +type MigrationDocument struct { + // Raw contains the did.Document in bytes. For did:nuts this is must be equal to the payload in the network transaction. + Raw []byte + Created time.Time + Updated time.Time + Version int +} + +// ToORMDocument converts the Raw document to a DidDocument. Generates a new DidDocument.ID +func (migration MigrationDocument) ToORMDocument(subject string) (DidDocument, error) { + doc := new(did.Document) + err := json.Unmarshal(migration.Raw, doc) + if err != nil { + return DidDocument{}, err + } + + // generate DB documentID + documentID := uuid.New().String() + + // convert []did.VerificationMethod to []VerificationMethod + vms := make([]VerificationMethod, len(doc.VerificationMethod)) + for i, vm := range doc.VerificationMethod { + vmAsJson, _ := json.Marshal(*vm) + vms[i] = VerificationMethod{ + ID: vm.ID.String(), + KeyTypes: VerificationMethodKeyType(verificationMethodToKeyFlags(*doc, vm)), + Data: vmAsJson, + } + } + + // convert []did.Service to []Service + services := make([]Service, len(doc.Service)) + for i, service := range doc.Service { + asJson, _ := json.Marshal(service) + services[i] = Service{ + ID: service.ID.String(), + Data: asJson, + } + } + + // DID + subjectDID := DID{ + ID: doc.ID.String(), + Subject: subject, + } + + // return document + return DidDocument{ + ID: documentID, + DidID: doc.ID.String(), + DID: subjectDID, + CreatedAt: migration.Created.Unix(), + UpdatedAt: migration.Updated.Unix(), + Version: migration.Version, + VerificationMethods: vms, + Services: services, + Raw: string(migration.Raw), + }, nil +} diff --git a/storage/orm/did_document_test.go b/storage/orm/did_document_test.go index 8a532ded00..9c2f886362 100644 --- a/storage/orm/did_document_test.go +++ b/storage/orm/did_document_test.go @@ -1,11 +1,12 @@ package orm import ( + "encoding/json" "github.com/nuts-foundation/go-did/did" - "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "testing" + "time" ) var ( @@ -44,3 +45,65 @@ func TestDIDDocument_ToDIDDocument(t *testing.T) { assert.Equal(t, "#1", didDoc.VerificationMethod[0].ID.String()) assert.Equal(t, "#2", didDoc.Service[0].ID.String()) } + +func TestDIDDocument_FromDIDDocument(t *testing.T) { + created := time.Now() + updated := created.Add(time.Second) + version := 4 + vms := []VerificationMethod{ + { + ID: "#1", + Data: []byte(`{"id":"#1"}`), + KeyTypes: VerificationMethodKeyType(AssertionMethodUsage | KeyAgreementUsage), + }, { + ID: "#2", + Data: []byte(`{"id":"#2"}`), + KeyTypes: VerificationMethodKeyType(KeyAgreementUsage), + }, + } + service := Service{ + ID: "#service", + Data: []byte(`{"id":"#service"}`), + } + didDoc, err := DidDocument{ + DID: DID{ID: alice.String()}, + VerificationMethods: vms, + Services: []Service{service}, + CreatedAt: created.Unix(), + UpdatedAt: updated.Unix(), + }.ToDIDDocument() + require.NoError(t, err) + docRaw, err := json.Marshal(didDoc) + require.NoError(t, err) + + result, err := MigrationDocument{ + Version: version, + Created: created, + Updated: updated, + Raw: docRaw, + }.ToORMDocument("test-subject") + require.NoError(t, err) + + assert.NotEmpty(t, result.ID) + assert.Equal(t, alice.String(), result.DidID) + assert.Equal(t, created.Unix(), result.CreatedAt) + assert.Equal(t, updated.Unix(), result.UpdatedAt) + assert.Equal(t, version, result.Version) + + // DID + assert.Equal(t, DID{ + ID: alice.String(), + Subject: "test-subject", + }, result.DID) + + // Services + require.Len(t, result.Services, 1) + assert.Equal(t, service, result.Services[0]) + + // VerificationMethods + require.Len(t, result.VerificationMethods, 2) + assert.Equal(t, "#1", result.VerificationMethods[0].ID) + assert.Equal(t, VerificationMethodKeyType(AssertionMethodUsage|KeyAgreementUsage), result.VerificationMethods[0].KeyTypes) + assert.Equal(t, "#2", result.VerificationMethods[1].ID) + assert.Equal(t, VerificationMethodKeyType(KeyAgreementUsage), result.VerificationMethods[1].KeyTypes) +} diff --git a/storage/orm/keyflag.go b/storage/orm/keyflag.go index 46a13ff8d8..b04f8dc433 100644 --- a/storage/orm/keyflag.go +++ b/storage/orm/keyflag.go @@ -18,6 +18,8 @@ package orm +import "github.com/nuts-foundation/go-did/did" + // DIDKeyFlags is a bitmask used for specifying for what purposes a key in a DID document can be used (a.k.a. Verification Method relationships). type DIDKeyFlags uint @@ -46,3 +48,24 @@ func AssertionKeyUsage() DIDKeyFlags { func EncryptionKeyUsage() DIDKeyFlags { return KeyAgreementUsage } + +// verificationMethodToKeyFlags creates DIDKeyFlags for a did.VerificationMethod based on its usage in the did.Document. +func verificationMethodToKeyFlags(document did.Document, vm *did.VerificationMethod) DIDKeyFlags { + var flags DIDKeyFlags + if document.Authentication.FindByID(vm.ID) != nil { + flags |= AuthenticationUsage + } + if document.AssertionMethod.FindByID(vm.ID) != nil { + flags |= AssertionMethodUsage + } + if document.CapabilityDelegation.FindByID(vm.ID) != nil { + flags |= CapabilityDelegationUsage + } + if document.CapabilityInvocation.FindByID(vm.ID) != nil { + flags |= CapabilityInvocationUsage + } + if document.KeyAgreement.FindByID(vm.ID) != nil { + flags |= KeyAgreementUsage + } + return flags +} diff --git a/storage/orm/keyflag_test.go b/storage/orm/keyflag_test.go index db6fbe57ee..550c6e180b 100644 --- a/storage/orm/keyflag_test.go +++ b/storage/orm/keyflag_test.go @@ -19,6 +19,7 @@ package orm import ( + "github.com/nuts-foundation/go-did/did" "testing" "github.com/stretchr/testify/assert" @@ -55,3 +56,43 @@ func TestKeyUsage_Is(t *testing.T) { } }) } + +func Test_verificationMethodToKeyFlags(t *testing.T) { + vr1 := did.VerificationRelationship{VerificationMethod: &did.VerificationMethod{ + ID: did.MustParseDIDURL("did:method:something#key-1"), + }} + vr2 := did.VerificationRelationship{VerificationMethod: &did.VerificationMethod{ + ID: did.MustParseDIDURL("did:method:something#key-2"), + }} + t.Run("single-key", func(t *testing.T) { + t.Run("AssertionMethod", func(t *testing.T) { + doc := did.Document{AssertionMethod: did.VerificationRelationships{vr1}} + assert.Equal(t, AssertionMethodUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("Authentication", func(t *testing.T) { + doc := did.Document{Authentication: did.VerificationRelationships{vr1}} + assert.Equal(t, AuthenticationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("CapabilityDelegation", func(t *testing.T) { + doc := did.Document{CapabilityDelegation: did.VerificationRelationships{vr1}} + assert.Equal(t, CapabilityDelegationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("CapabilityInvocation", func(t *testing.T) { + doc := did.Document{CapabilityInvocation: did.VerificationRelationships{vr1}} + assert.Equal(t, CapabilityInvocationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("KeyAgreement", func(t *testing.T) { + doc := did.Document{KeyAgreement: did.VerificationRelationships{vr1}} + assert.Equal(t, KeyAgreementUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + }) + t.Run("multi-key", func(t *testing.T) { + doc := did.Document{ + AssertionMethod: did.VerificationRelationships{vr1}, + CapabilityInvocation: did.VerificationRelationships{vr1, vr2}, + KeyAgreement: did.VerificationRelationships{vr2}, + } + assert.Equal(t, AssertionMethodUsage|CapabilityInvocationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + assert.Equal(t, CapabilityInvocationUsage|KeyAgreementUsage, verificationMethodToKeyFlags(doc, vr2.VerificationMethod)) + }) +} diff --git a/storage/sql_migrations/009_discoveryservice_seed.sql b/storage/sql_migrations/009_discoveryservice_seed.sql new file mode 100644 index 0000000000..4d0e48a9fe --- /dev/null +++ b/storage/sql_migrations/009_discoveryservice_seed.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- discovery_service: add seed column +alter table discovery_service add seed varchar(36); + +-- +goose Down +alter table discovery_service drop column seed; diff --git a/storage/sql_migrations/010_discoverypresentation_validation.sql b/storage/sql_migrations/010_discoverypresentation_validation.sql new file mode 100644 index 0000000000..381a6b20de --- /dev/null +++ b/storage/sql_migrations/010_discoverypresentation_validation.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- discovery_presentation: add validated column +alter table discovery_presentation add validated SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +alter table discovery_presentation drop column validated; diff --git a/storage/test.go b/storage/test.go index 21b348dd69..549b564cdd 100644 --- a/storage/test.go +++ b/storage/test.go @@ -21,6 +21,7 @@ package storage import ( "context" "errors" + "fmt" "github.com/alicebob/miniredis/v2" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/test/io" @@ -70,6 +71,7 @@ func NewTestStorageEngineInDir(t testing.TB, dir string) Engine { t.Cleanup(func() { _ = result.Shutdown() }) + fmt.Printf("Created test storage engine in %s\n", dir) return result } @@ -127,7 +129,7 @@ func NewTestInMemorySessionDatabase(t *testing.T) *InMemorySessionDatabase { func AddDIDtoSQLDB(t testing.TB, db *gorm.DB, dids ...did.DID) { for _, id := range dids { // use gorm EXEC since it accepts '?' as the argument placeholder for all DBs - require.NoError(t, db.Exec("INSERT INTO did (subject, id ) VALUES ( ?, ? )", id.String(), id.String(), id.String()).Error) + require.NoError(t, db.Exec("INSERT INTO did ( subject, id ) VALUES ( ?, ? )", id.String(), id.String(), id.String()).Error) } } diff --git a/test/node/server.go b/test/node/server.go index 5043f89518..7433f13b89 100644 --- a/test/node/server.go +++ b/test/node/server.go @@ -75,7 +75,7 @@ func StartServer(t *testing.T, configFunc ...func(internalHttpServerURL, publicH t.Setenv("NUTS_EVENTS_NATS_PORT", natsPort) t.Setenv("NUTS_EVENTS_NATS_HOSTNAME", "localhost") t.Setenv("NUTS_URL", publicHttpServerURL) - t.Setenv("NUTS_VDR_DIDMETHODS", "nuts") + t.Setenv("NUTS_DIDMETHODS", "nuts") for _, fn := range configFunc { fn(internalHttpServerURL, publicHttpServerURL) diff --git a/vcr/ambassador.go b/vcr/ambassador.go index 817e103170..63dd274816 100644 --- a/vcr/ambassador.go +++ b/vcr/ambassador.go @@ -99,9 +99,7 @@ func (n ambassador) Start() error { return fmt.Errorf("failed to subscribe to REPROCESS event stream: %v", err) } - // removing failed events required for #1743 - // remove after v6 release - return n.networkClient.CleanupSubscriberEvents("vcr_vcs", "canonicalization failed: unable to normalize the json-ld document: loading remote context failed: Dereferencing a URL did not result in a valid JSON-LD context") + return nil } func (n ambassador) handleNetworkVCs(event dag.Event) (bool, error) { diff --git a/vcr/api/openid4vci/v0/generated.go b/vcr/api/openid4vci/v0/generated.go index d6c6a149bf..19426c73b4 100644 --- a/vcr/api/openid4vci/v0/generated.go +++ b/vcr/api/openid4vci/v0/generated.go @@ -1,6 +1,6 @@ // Package v0 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v0 import ( diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 10c9275a70..e9f2763da4 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -23,15 +23,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/jsonld" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/holder" - "github.com/nuts-foundation/nuts-node/vcr/issuer" - vcrTypes "github.com/nuts-foundation/nuts-node/vcr/types" - "github.com/nuts-foundation/nuts-node/vcr/verifier" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" "strings" "time" @@ -40,9 +31,18 @@ import ( 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/audit" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + vcrTypes "github.com/nuts-foundation/nuts-node/vcr/types" + "github.com/nuts-foundation/nuts-node/vcr/verifier" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) var clockFn = func() time.Time { @@ -429,7 +429,7 @@ func (w *Wrapper) LoadVC(ctx context.Context, request LoadVCRequestObject) (Load // validate credential if err = w.VCR.Verifier().Verify(*request.Body, true, true, nil); err != nil { if errors.Is(err, verifier.VerificationError{}) { - return nil, core.InvalidInputError(err.Error()) + return nil, core.InvalidInputError("%w", err) } return nil, err } @@ -461,20 +461,31 @@ func (w *Wrapper) GetCredentialsInWallet(ctx context.Context, request GetCredent } func (w *Wrapper) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) { - holderDID, err := did.ParseDID(request.Did) + // get DIDs for holder + dids, err := w.SubjectManager.ListDIDs(ctx, request.SubjectID) if err != nil { - return nil, core.InvalidInputError("invalid holder DID: %w", err) + return nil, err } credentialID, err := ssi.ParseURI(request.Id) if err != nil { return nil, core.InvalidInputError("invalid credential ID: %w", err) } - err = w.VCR.Wallet().Remove(ctx, *holderDID, *credentialID) - if err != nil { - return nil, err + var deleted bool + for _, subjectDID := range dids { + err = w.VCR.Wallet().Remove(ctx, subjectDID, *credentialID) + if err != nil { + if errors.Is(err, vcrTypes.ErrNotFound) { + // only return vcrTypes.ErrNotFound if true for all subjectDIDs (deleted=false) + continue + } + return nil, err + } + deleted = true + } + if !deleted { + return nil, vcrTypes.ErrNotFound } return RemoveCredentialFromWallet204Response{}, nil - } // TrustIssuer handles API request to start trusting an issuer of a Verifiable Credential. diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index ce0fcb7304..6c6ae24d55 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -23,8 +23,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/nuts-foundation/nuts-node/vcr/types" "net/http" "testing" "time" @@ -41,6 +40,8 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vcr/verifier" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -827,26 +828,57 @@ func TestWrapper_GetCredentialsInWallet(t *testing.T) { }) } -func TestWrapper_RemoveCredentialFromWallet(t *testing.T) { +func TestWrapper_RemoveCredentialFromSubjectWallet(t *testing.T) { + didNuts := did.MustParseDID("did:nuts:123") + didWeb := did.MustParseDID("did:web:example.com") + subject := "subbie" t.Run("ok", func(t *testing.T) { testContext := newMockContext(t) - testContext.mockWallet.EXPECT().Remove(testContext.requestCtx, holderDID, credentialID).Return(nil) + testContext.mockSubjectManager.EXPECT().ListDIDs(testContext.requestCtx, subject).Return([]did.DID{didNuts, didWeb}, nil) + testContext.mockWallet.EXPECT().Remove(testContext.requestCtx, didNuts, credentialID).Return(nil) + testContext.mockWallet.EXPECT().Remove(testContext.requestCtx, didWeb, credentialID).Return(types.ErrNotFound) // only exists on 1 DID response, err := testContext.client.RemoveCredentialFromWallet(testContext.requestCtx, RemoveCredentialFromWalletRequestObject{ - Did: holderDID.String(), - Id: credentialID.String(), + SubjectID: subject, + Id: credentialID.String(), }) assert.NoError(t, err) assert.Equal(t, RemoveCredentialFromWallet204Response{}, response) }) - t.Run("error", func(t *testing.T) { + t.Run("error - credential not found", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().ListDIDs(testContext.requestCtx, subject).Return([]did.DID{didNuts, didWeb}, nil) + testContext.mockWallet.EXPECT().Remove(testContext.requestCtx, gomock.AnyOf(didNuts, didWeb), credentialID).Return(types.ErrNotFound).Times(2) + + response, err := testContext.client.RemoveCredentialFromWallet(testContext.requestCtx, RemoveCredentialFromWalletRequestObject{ + SubjectID: subject, + Id: credentialID.String(), + }) + + assert.Empty(t, response) + assert.ErrorIs(t, err, types.ErrNotFound) + }) + t.Run("error - subject not found", func(t *testing.T) { + testContext := newMockContext(t) + testContext.mockSubjectManager.EXPECT().ListDIDs(testContext.requestCtx, subject).Return(nil, didsubject.ErrSubjectNotFound) + + response, err := testContext.client.RemoveCredentialFromWallet(testContext.requestCtx, RemoveCredentialFromWalletRequestObject{ + SubjectID: subject, + Id: credentialID.String(), + }) + + assert.Empty(t, response) + assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) + }) + t.Run("error - general error", func(t *testing.T) { testContext := newMockContext(t) - testContext.mockWallet.EXPECT().Remove(testContext.requestCtx, holderDID, credentialID).Return(assert.AnError) + testContext.mockSubjectManager.EXPECT().ListDIDs(testContext.requestCtx, subject).Return([]did.DID{didNuts, didWeb}, nil) + testContext.mockWallet.EXPECT().Remove(testContext.requestCtx, didNuts, credentialID).Return(assert.AnError) response, err := testContext.client.RemoveCredentialFromWallet(testContext.requestCtx, RemoveCredentialFromWalletRequestObject{ - Did: holderDID.String(), - Id: credentialID.String(), + SubjectID: subject, + Id: credentialID.String(), }) assert.Empty(t, response) diff --git a/vcr/api/vcr/v2/client.go b/vcr/api/vcr/v2/client.go index bb77afb380..cbf3b208e3 100644 --- a/vcr/api/vcr/v2/client.go +++ b/vcr/api/vcr/v2/client.go @@ -36,7 +36,7 @@ type HTTPClient struct { } func (hb HTTPClient) client() ClientInterface { - response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateHTTPClient(hb.ClientConfig, hb.TokenGenerator))) + response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateInternalHTTPClient(hb.ClientConfig, hb.TokenGenerator))) if err != nil { panic(err) } diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 5ecdc78828..3e31b3c920 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -1,6 +1,6 @@ // Package v2 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v2 import ( @@ -482,9 +482,6 @@ type ClientInterface interface { CreateVP(ctx context.Context, body CreateVPJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // RemoveCredentialFromWallet request - RemoveCredentialFromWallet(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) - // GetCredentialsInWallet request GetCredentialsInWallet(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -493,6 +490,9 @@ type ClientInterface interface { LoadVC(ctx context.Context, subjectID string, body LoadVCJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // RemoveCredentialFromWallet request + RemoveCredentialFromWallet(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + // IssueVCWithBody request with any body IssueVCWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -563,8 +563,8 @@ func (c *Client) CreateVP(ctx context.Context, body CreateVPJSONRequestBody, req return c.Client.Do(req) } -func (c *Client) RemoveCredentialFromWallet(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewRemoveCredentialFromWalletRequest(c.Server, did, id) +func (c *Client) GetCredentialsInWallet(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetCredentialsInWalletRequest(c.Server, subjectID) if err != nil { return nil, err } @@ -575,8 +575,8 @@ func (c *Client) RemoveCredentialFromWallet(ctx context.Context, did string, id return c.Client.Do(req) } -func (c *Client) GetCredentialsInWallet(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetCredentialsInWalletRequest(c.Server, subjectID) +func (c *Client) LoadVCWithBody(ctx context.Context, subjectID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewLoadVCRequestWithBody(c.Server, subjectID, contentType, body) if err != nil { return nil, err } @@ -587,8 +587,8 @@ func (c *Client) GetCredentialsInWallet(ctx context.Context, subjectID string, r return c.Client.Do(req) } -func (c *Client) LoadVCWithBody(ctx context.Context, subjectID string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewLoadVCRequestWithBody(c.Server, subjectID, contentType, body) +func (c *Client) LoadVC(ctx context.Context, subjectID string, body LoadVCJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewLoadVCRequest(c.Server, subjectID, body) if err != nil { return nil, err } @@ -599,8 +599,8 @@ func (c *Client) LoadVCWithBody(ctx context.Context, subjectID string, contentTy return c.Client.Do(req) } -func (c *Client) LoadVC(ctx context.Context, subjectID string, body LoadVCJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewLoadVCRequest(c.Server, subjectID, body) +func (c *Client) RemoveCredentialFromWallet(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRemoveCredentialFromWalletRequest(c.Server, subjectID, id) if err != nil { return nil, err } @@ -855,27 +855,20 @@ func NewCreateVPRequestWithBody(server string, contentType string, body io.Reade return req, nil } -// NewRemoveCredentialFromWalletRequest generates requests for RemoveCredentialFromWallet -func NewRemoveCredentialFromWalletRequest(server string, did string, id string) (*http.Request, error) { +// NewGetCredentialsInWalletRequest generates requests for GetCredentialsInWallet +func NewGetCredentialsInWalletRequest(server string, subjectID string) (*http.Request, error) { var err error var pathParam0 string - pathParam0 = did - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) - if err != nil { - return nil, err - } + pathParam0 = subjectID serverURL, err := url.Parse(server) if err != nil { return nil, err } - operationPath := fmt.Sprintf("/internal/vcr/v2/holder/%s/vc/%s", pathParam0, pathParam1) + operationPath := fmt.Sprintf("/internal/vcr/v2/holder/%s/vc", pathParam0) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -885,7 +878,7 @@ func NewRemoveCredentialFromWalletRequest(server string, did string, id string) return nil, err } - req, err := http.NewRequest("DELETE", queryURL.String(), nil) + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err } @@ -893,8 +886,19 @@ func NewRemoveCredentialFromWalletRequest(server string, did string, id string) return req, nil } -// NewGetCredentialsInWalletRequest generates requests for GetCredentialsInWallet -func NewGetCredentialsInWalletRequest(server string, subjectID string) (*http.Request, error) { +// NewLoadVCRequest calls the generic LoadVC builder with application/json body +func NewLoadVCRequest(server string, subjectID string, body LoadVCJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewLoadVCRequestWithBody(server, subjectID, "application/json", bodyReader) +} + +// NewLoadVCRequestWithBody generates requests for LoadVC with any type of body +func NewLoadVCRequestWithBody(server string, subjectID string, contentType string, body io.Reader) (*http.Request, error) { var err error var pathParam0 string @@ -916,39 +920,37 @@ func NewGetCredentialsInWalletRequest(server string, subjectID string) (*http.Re return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest("POST", queryURL.String(), body) if err != nil { return nil, err } - return req, nil -} + req.Header.Add("Content-Type", contentType) -// NewLoadVCRequest calls the generic LoadVC builder with application/json body -func NewLoadVCRequest(server string, subjectID string, body LoadVCJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewLoadVCRequestWithBody(server, subjectID, "application/json", bodyReader) + return req, nil } -// NewLoadVCRequestWithBody generates requests for LoadVC with any type of body -func NewLoadVCRequestWithBody(server string, subjectID string, contentType string, body io.Reader) (*http.Request, error) { +// NewRemoveCredentialFromWalletRequest generates requests for RemoveCredentialFromWallet +func NewRemoveCredentialFromWalletRequest(server string, subjectID string, id string) (*http.Request, error) { var err error var pathParam0 string pathParam0 = subjectID + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + serverURL, err := url.Parse(server) if err != nil { return nil, err } - operationPath := fmt.Sprintf("/internal/vcr/v2/holder/%s/vc", pathParam0) + operationPath := fmt.Sprintf("/internal/vcr/v2/holder/%s/vc/%s", pathParam0, pathParam1) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -958,13 +960,11 @@ func NewLoadVCRequestWithBody(server string, subjectID string, contentType strin return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest("DELETE", queryURL.String(), nil) if err != nil { return nil, err } - req.Header.Add("Content-Type", contentType) - return req, nil } @@ -1465,9 +1465,6 @@ type ClientWithResponsesInterface interface { CreateVPWithResponse(ctx context.Context, body CreateVPJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateVPResponse, error) - // RemoveCredentialFromWalletWithResponse request - RemoveCredentialFromWalletWithResponse(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*RemoveCredentialFromWalletResponse, error) - // GetCredentialsInWalletWithResponse request GetCredentialsInWalletWithResponse(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*GetCredentialsInWalletResponse, error) @@ -1476,6 +1473,9 @@ type ClientWithResponsesInterface interface { LoadVCWithResponse(ctx context.Context, subjectID string, body LoadVCJSONRequestBody, reqEditors ...RequestEditorFn) (*LoadVCResponse, error) + // RemoveCredentialFromWalletWithResponse request + RemoveCredentialFromWalletWithResponse(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*RemoveCredentialFromWalletResponse, error) + // IssueVCWithBodyWithResponse request with any body IssueVCWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*IssueVCResponse, error) @@ -1554,9 +1554,10 @@ func (r CreateVPResponse) StatusCode() int { return 0 } -type RemoveCredentialFromWalletResponse struct { +type GetCredentialsInWalletResponse struct { Body []byte HTTPResponse *http.Response + JSON200 *[]VerifiableCredential ApplicationproblemJSONDefault *struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -1570,7 +1571,7 @@ type RemoveCredentialFromWalletResponse struct { } // Status returns HTTPResponse.Status -func (r RemoveCredentialFromWalletResponse) Status() string { +func (r GetCredentialsInWalletResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -1578,17 +1579,16 @@ func (r RemoveCredentialFromWalletResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r RemoveCredentialFromWalletResponse) StatusCode() int { +func (r GetCredentialsInWalletResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type GetCredentialsInWalletResponse struct { +type LoadVCResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *[]VerifiableCredential ApplicationproblemJSONDefault *struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -1602,7 +1602,7 @@ type GetCredentialsInWalletResponse struct { } // Status returns HTTPResponse.Status -func (r GetCredentialsInWalletResponse) Status() string { +func (r LoadVCResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -1610,14 +1610,14 @@ func (r GetCredentialsInWalletResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r GetCredentialsInWalletResponse) StatusCode() int { +func (r LoadVCResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type LoadVCResponse struct { +type RemoveCredentialFromWalletResponse struct { Body []byte HTTPResponse *http.Response ApplicationproblemJSONDefault *struct { @@ -1633,7 +1633,7 @@ type LoadVCResponse struct { } // Status returns HTTPResponse.Status -func (r LoadVCResponse) Status() string { +func (r RemoveCredentialFromWalletResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -1641,7 +1641,7 @@ func (r LoadVCResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r LoadVCResponse) StatusCode() int { +func (r RemoveCredentialFromWalletResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -2015,15 +2015,6 @@ func (c *ClientWithResponses) CreateVPWithResponse(ctx context.Context, body Cre return ParseCreateVPResponse(rsp) } -// RemoveCredentialFromWalletWithResponse request returning *RemoveCredentialFromWalletResponse -func (c *ClientWithResponses) RemoveCredentialFromWalletWithResponse(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*RemoveCredentialFromWalletResponse, error) { - rsp, err := c.RemoveCredentialFromWallet(ctx, did, id, reqEditors...) - if err != nil { - return nil, err - } - return ParseRemoveCredentialFromWalletResponse(rsp) -} - // GetCredentialsInWalletWithResponse request returning *GetCredentialsInWalletResponse func (c *ClientWithResponses) GetCredentialsInWalletWithResponse(ctx context.Context, subjectID string, reqEditors ...RequestEditorFn) (*GetCredentialsInWalletResponse, error) { rsp, err := c.GetCredentialsInWallet(ctx, subjectID, reqEditors...) @@ -2050,6 +2041,15 @@ func (c *ClientWithResponses) LoadVCWithResponse(ctx context.Context, subjectID return ParseLoadVCResponse(rsp) } +// RemoveCredentialFromWalletWithResponse request returning *RemoveCredentialFromWalletResponse +func (c *ClientWithResponses) RemoveCredentialFromWalletWithResponse(ctx context.Context, subjectID string, id string, reqEditors ...RequestEditorFn) (*RemoveCredentialFromWalletResponse, error) { + rsp, err := c.RemoveCredentialFromWallet(ctx, subjectID, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseRemoveCredentialFromWalletResponse(rsp) +} + // IssueVCWithBodyWithResponse request with arbitrary body returning *IssueVCResponse func (c *ClientWithResponses) IssueVCWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*IssueVCResponse, error) { rsp, err := c.IssueVCWithBody(ctx, contentType, body, reqEditors...) @@ -2239,20 +2239,27 @@ func ParseCreateVPResponse(rsp *http.Response) (*CreateVPResponse, error) { return response, nil } -// ParseRemoveCredentialFromWalletResponse parses an HTTP response from a RemoveCredentialFromWalletWithResponse call -func ParseRemoveCredentialFromWalletResponse(rsp *http.Response) (*RemoveCredentialFromWalletResponse, error) { +// ParseGetCredentialsInWalletResponse parses an HTTP response from a GetCredentialsInWalletWithResponse call +func ParseGetCredentialsInWalletResponse(rsp *http.Response) (*GetCredentialsInWalletResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &RemoveCredentialFromWalletResponse{ + response := &GetCredentialsInWalletResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []VerifiableCredential + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &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. @@ -2274,27 +2281,20 @@ func ParseRemoveCredentialFromWalletResponse(rsp *http.Response) (*RemoveCredent return response, nil } -// ParseGetCredentialsInWalletResponse parses an HTTP response from a GetCredentialsInWalletWithResponse call -func ParseGetCredentialsInWalletResponse(rsp *http.Response) (*GetCredentialsInWalletResponse, error) { +// ParseLoadVCResponse parses an HTTP response from a LoadVCWithResponse call +func ParseLoadVCResponse(rsp *http.Response) (*LoadVCResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &GetCredentialsInWalletResponse{ + response := &LoadVCResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest []VerifiableCredential - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &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. @@ -2316,15 +2316,15 @@ func ParseGetCredentialsInWalletResponse(rsp *http.Response) (*GetCredentialsInW return response, nil } -// ParseLoadVCResponse parses an HTTP response from a LoadVCWithResponse call -func ParseLoadVCResponse(rsp *http.Response) (*LoadVCResponse, error) { +// ParseRemoveCredentialFromWalletResponse parses an HTTP response from a RemoveCredentialFromWalletWithResponse call +func ParseRemoveCredentialFromWalletResponse(rsp *http.Response) (*RemoveCredentialFromWalletResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &LoadVCResponse{ + response := &RemoveCredentialFromWalletResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2804,15 +2804,15 @@ type ServerInterface interface { // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx echo.Context) error - // Remove a VerifiableCredential from the holders wallet. - // (DELETE /internal/vcr/v2/holder/{did}/vc/{id}) - RemoveCredentialFromWallet(ctx echo.Context, did string, id string) error // List all Verifiable Credentials in the holder's wallet. // (GET /internal/vcr/v2/holder/{subjectID}/vc) GetCredentialsInWallet(ctx echo.Context, subjectID string) error // Load a VerifiableCredential into the holders wallet. // (POST /internal/vcr/v2/holder/{subjectID}/vc) LoadVC(ctx echo.Context, subjectID string) error + // Remove a VerifiableCredential from the holders wallet. + // (DELETE /internal/vcr/v2/holder/{subjectID}/vc/{id}) + RemoveCredentialFromWallet(ctx echo.Context, subjectID string, id string) error // Issues a new Verifiable Credential // (POST /internal/vcr/v2/issuer/vc) IssueVC(ctx echo.Context) error @@ -2864,31 +2864,23 @@ func (w *ServerInterfaceWrapper) CreateVP(ctx echo.Context) error { return err } -// RemoveCredentialFromWallet converts echo context to params. -func (w *ServerInterfaceWrapper) RemoveCredentialFromWallet(ctx echo.Context) error { +// GetCredentialsInWallet converts echo context to params. +func (w *ServerInterfaceWrapper) GetCredentialsInWallet(ctx echo.Context) error { var err error - // ------------- Path parameter "did" ------------- - var did string - - did = ctx.Param("did") - - // ------------- Path parameter "id" ------------- - var id string + // ------------- Path parameter "subjectID" ------------- + var subjectID string - err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) - } + subjectID = ctx.Param("subjectID") ctx.Set(JwtBearerAuthScopes, []string{}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.RemoveCredentialFromWallet(ctx, did, id) + err = w.Handler.GetCredentialsInWallet(ctx, subjectID) return err } -// GetCredentialsInWallet converts echo context to params. -func (w *ServerInterfaceWrapper) GetCredentialsInWallet(ctx echo.Context) error { +// LoadVC converts echo context to params. +func (w *ServerInterfaceWrapper) LoadVC(ctx echo.Context) error { var err error // ------------- Path parameter "subjectID" ------------- var subjectID string @@ -2898,22 +2890,30 @@ func (w *ServerInterfaceWrapper) GetCredentialsInWallet(ctx echo.Context) error ctx.Set(JwtBearerAuthScopes, []string{}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetCredentialsInWallet(ctx, subjectID) + err = w.Handler.LoadVC(ctx, subjectID) return err } -// LoadVC converts echo context to params. -func (w *ServerInterfaceWrapper) LoadVC(ctx echo.Context) error { +// RemoveCredentialFromWallet converts echo context to params. +func (w *ServerInterfaceWrapper) RemoveCredentialFromWallet(ctx echo.Context) error { var err error // ------------- Path parameter "subjectID" ------------- var subjectID string subjectID = ctx.Param("subjectID") + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + ctx.Set(JwtBearerAuthScopes, []string{}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.LoadVC(ctx, subjectID) + err = w.Handler.RemoveCredentialFromWallet(ctx, subjectID, id) return err } @@ -3118,9 +3118,9 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } router.POST(baseURL+"/internal/vcr/v2/holder/vp", wrapper.CreateVP) - router.DELETE(baseURL+"/internal/vcr/v2/holder/:did/vc/:id", wrapper.RemoveCredentialFromWallet) router.GET(baseURL+"/internal/vcr/v2/holder/:subjectID/vc", wrapper.GetCredentialsInWallet) router.POST(baseURL+"/internal/vcr/v2/holder/:subjectID/vc", wrapper.LoadVC) + router.DELETE(baseURL+"/internal/vcr/v2/holder/:subjectID/vc/:id", wrapper.RemoveCredentialFromWallet) router.POST(baseURL+"/internal/vcr/v2/issuer/vc", wrapper.IssueVC) router.GET(baseURL+"/internal/vcr/v2/issuer/vc/search", wrapper.SearchIssuedVCs) router.DELETE(baseURL+"/internal/vcr/v2/issuer/vc/:id", wrapper.RevokeVC) @@ -3173,24 +3173,24 @@ func (response CreateVPdefaultApplicationProblemPlusJSONResponse) VisitCreateVPR return json.NewEncoder(w).Encode(response.Body) } -type RemoveCredentialFromWalletRequestObject struct { - Did string `json:"did"` - Id string `json:"id"` +type GetCredentialsInWalletRequestObject struct { + SubjectID string `json:"subjectID"` } -type RemoveCredentialFromWalletResponseObject interface { - VisitRemoveCredentialFromWalletResponse(w http.ResponseWriter) error +type GetCredentialsInWalletResponseObject interface { + VisitGetCredentialsInWalletResponse(w http.ResponseWriter) error } -type RemoveCredentialFromWallet204Response struct { -} +type GetCredentialsInWallet200JSONResponse []VerifiableCredential -func (response RemoveCredentialFromWallet204Response) VisitRemoveCredentialFromWalletResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil +func (response GetCredentialsInWallet200JSONResponse) VisitGetCredentialsInWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) } -type RemoveCredentialFromWalletdefaultApplicationProblemPlusJSONResponse struct { +type GetCredentialsInWalletdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -3204,31 +3204,31 @@ type RemoveCredentialFromWalletdefaultApplicationProblemPlusJSONResponse struct StatusCode int } -func (response RemoveCredentialFromWalletdefaultApplicationProblemPlusJSONResponse) VisitRemoveCredentialFromWalletResponse(w http.ResponseWriter) error { +func (response GetCredentialsInWalletdefaultApplicationProblemPlusJSONResponse) VisitGetCredentialsInWalletResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(response.StatusCode) return json.NewEncoder(w).Encode(response.Body) } -type GetCredentialsInWalletRequestObject struct { +type LoadVCRequestObject struct { SubjectID string `json:"subjectID"` + Body *LoadVCJSONRequestBody } -type GetCredentialsInWalletResponseObject interface { - VisitGetCredentialsInWalletResponse(w http.ResponseWriter) error +type LoadVCResponseObject interface { + VisitLoadVCResponse(w http.ResponseWriter) error } -type GetCredentialsInWallet200JSONResponse []VerifiableCredential - -func (response GetCredentialsInWallet200JSONResponse) VisitGetCredentialsInWalletResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) +type LoadVC204Response struct { +} - return json.NewEncoder(w).Encode(response) +func (response LoadVC204Response) VisitLoadVCResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil } -type GetCredentialsInWalletdefaultApplicationProblemPlusJSONResponse struct { +type LoadVCdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -3242,31 +3242,31 @@ type GetCredentialsInWalletdefaultApplicationProblemPlusJSONResponse struct { StatusCode int } -func (response GetCredentialsInWalletdefaultApplicationProblemPlusJSONResponse) VisitGetCredentialsInWalletResponse(w http.ResponseWriter) error { +func (response LoadVCdefaultApplicationProblemPlusJSONResponse) VisitLoadVCResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(response.StatusCode) return json.NewEncoder(w).Encode(response.Body) } -type LoadVCRequestObject struct { +type RemoveCredentialFromWalletRequestObject struct { SubjectID string `json:"subjectID"` - Body *LoadVCJSONRequestBody + Id string `json:"id"` } -type LoadVCResponseObject interface { - VisitLoadVCResponse(w http.ResponseWriter) error +type RemoveCredentialFromWalletResponseObject interface { + VisitRemoveCredentialFromWalletResponse(w http.ResponseWriter) error } -type LoadVC204Response struct { +type RemoveCredentialFromWallet204Response struct { } -func (response LoadVC204Response) VisitLoadVCResponse(w http.ResponseWriter) error { +func (response RemoveCredentialFromWallet204Response) VisitRemoveCredentialFromWalletResponse(w http.ResponseWriter) error { w.WriteHeader(204) return nil } -type LoadVCdefaultApplicationProblemPlusJSONResponse struct { +type RemoveCredentialFromWalletdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -3280,7 +3280,7 @@ type LoadVCdefaultApplicationProblemPlusJSONResponse struct { StatusCode int } -func (response LoadVCdefaultApplicationProblemPlusJSONResponse) VisitLoadVCResponse(w http.ResponseWriter) error { +func (response RemoveCredentialFromWalletdefaultApplicationProblemPlusJSONResponse) VisitRemoveCredentialFromWalletResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(response.StatusCode) @@ -3716,15 +3716,15 @@ type StrictServerInterface interface { // Create a new Verifiable Presentation for a set of Verifiable Credentials. // (POST /internal/vcr/v2/holder/vp) CreateVP(ctx context.Context, request CreateVPRequestObject) (CreateVPResponseObject, error) - // Remove a VerifiableCredential from the holders wallet. - // (DELETE /internal/vcr/v2/holder/{did}/vc/{id}) - RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) // List all Verifiable Credentials in the holder's wallet. // (GET /internal/vcr/v2/holder/{subjectID}/vc) GetCredentialsInWallet(ctx context.Context, request GetCredentialsInWalletRequestObject) (GetCredentialsInWalletResponseObject, error) // Load a VerifiableCredential into the holders wallet. // (POST /internal/vcr/v2/holder/{subjectID}/vc) LoadVC(ctx context.Context, request LoadVCRequestObject) (LoadVCResponseObject, error) + // Remove a VerifiableCredential from the holders wallet. + // (DELETE /internal/vcr/v2/holder/{subjectID}/vc/{id}) + RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) // Issues a new Verifiable Credential // (POST /internal/vcr/v2/issuer/vc) IssueVC(ctx context.Context, request IssueVCRequestObject) (IssueVCResponseObject, error) @@ -3801,32 +3801,6 @@ func (sh *strictHandler) CreateVP(ctx echo.Context) error { return nil } -// RemoveCredentialFromWallet operation middleware -func (sh *strictHandler) RemoveCredentialFromWallet(ctx echo.Context, did string, id string) error { - var request RemoveCredentialFromWalletRequestObject - - request.Did = did - request.Id = id - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.RemoveCredentialFromWallet(ctx.Request().Context(), request.(RemoveCredentialFromWalletRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "RemoveCredentialFromWallet") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(RemoveCredentialFromWalletResponseObject); ok { - return validResponse.VisitRemoveCredentialFromWalletResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - // GetCredentialsInWallet operation middleware func (sh *strictHandler) GetCredentialsInWallet(ctx echo.Context, subjectID string) error { var request GetCredentialsInWalletRequestObject @@ -3883,6 +3857,32 @@ func (sh *strictHandler) LoadVC(ctx echo.Context, subjectID string) error { return nil } +// RemoveCredentialFromWallet operation middleware +func (sh *strictHandler) RemoveCredentialFromWallet(ctx echo.Context, subjectID string, id string) error { + var request RemoveCredentialFromWalletRequestObject + + request.SubjectID = subjectID + request.Id = id + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RemoveCredentialFromWallet(ctx.Request().Context(), request.(RemoveCredentialFromWalletRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RemoveCredentialFromWallet") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RemoveCredentialFromWalletResponseObject); ok { + return validResponse.VisitRemoveCredentialFromWalletResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // IssueVC operation middleware func (sh *strictHandler) IssueVC(ctx echo.Context) error { var request IssueVCRequestObject diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index 4fcd69dfae..69db728228 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -25,6 +25,7 @@ import ( type MockWallet struct { ctrl *gomock.Controller recorder *MockWalletMockRecorder + isgomock struct{} } // MockWalletMockRecorder is the mock recorder for MockWallet. diff --git a/vcr/holder/openid_mock.go b/vcr/holder/openid_mock.go index 402f2d72c8..8d6f45b655 100644 --- a/vcr/holder/openid_mock.go +++ b/vcr/holder/openid_mock.go @@ -21,6 +21,7 @@ import ( type MockOpenIDHandler struct { ctrl *gomock.Controller recorder *MockOpenIDHandlerMockRecorder + isgomock struct{} } // MockOpenIDHandlerMockRecorder is the mock recorder for MockOpenIDHandler. diff --git a/vcr/issuer/mock.go b/vcr/issuer/mock.go index 23961a6080..140e958566 100644 --- a/vcr/issuer/mock.go +++ b/vcr/issuer/mock.go @@ -25,6 +25,7 @@ import ( type MockPublisher struct { ctrl *gomock.Controller recorder *MockPublisherMockRecorder + isgomock struct{} } // MockPublisherMockRecorder is the mock recorder for MockPublisher. @@ -76,6 +77,7 @@ func (mr *MockPublisherMockRecorder) PublishRevocation(ctx, revocation any) *gom type MockIssuer struct { ctrl *gomock.Controller recorder *MockIssuerMockRecorder + isgomock struct{} } // MockIssuerMockRecorder is the mock recorder for MockIssuer. @@ -159,6 +161,7 @@ func (mr *MockIssuerMockRecorder) StatusList(ctx, issuer, page any) *gomock.Call type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder + isgomock struct{} } // MockStoreMockRecorder is the mock recorder for MockStore. @@ -283,6 +286,7 @@ func (mr *MockStoreMockRecorder) StoreRevocation(r any) *gomock.Call { type MockCredentialSearcher struct { ctrl *gomock.Controller recorder *MockCredentialSearcherMockRecorder + isgomock struct{} } // MockCredentialSearcherMockRecorder is the mock recorder for MockCredentialSearcher. diff --git a/vcr/issuer/openid_mock.go b/vcr/issuer/openid_mock.go index 367ddca9c1..1eb709fca9 100644 --- a/vcr/issuer/openid_mock.go +++ b/vcr/issuer/openid_mock.go @@ -22,6 +22,7 @@ import ( type MockOpenIDHandler struct { ctrl *gomock.Controller recorder *MockOpenIDHandlerMockRecorder + isgomock struct{} } // MockOpenIDHandlerMockRecorder is the mock recorder for MockOpenIDHandler. diff --git a/vcr/mock.go b/vcr/mock.go index a04f6a1f51..64b76b0a42 100644 --- a/vcr/mock.go +++ b/vcr/mock.go @@ -27,6 +27,7 @@ import ( type MockFinder struct { ctrl *gomock.Controller recorder *MockFinderMockRecorder + isgomock struct{} } // MockFinderMockRecorder is the mock recorder for MockFinder. @@ -65,6 +66,7 @@ func (mr *MockFinderMockRecorder) Search(ctx, searchTerms, allowUntrusted, resol type MockTrustManager struct { ctrl *gomock.Controller recorder *MockTrustManagerMockRecorder + isgomock struct{} } // MockTrustManagerMockRecorder is the mock recorder for MockTrustManager. @@ -146,6 +148,7 @@ func (mr *MockTrustManagerMockRecorder) Untrusted(credentialType any) *gomock.Ca type MockResolver struct { ctrl *gomock.Controller recorder *MockResolverMockRecorder + isgomock struct{} } // MockResolverMockRecorder is the mock recorder for MockResolver. @@ -184,6 +187,7 @@ func (mr *MockResolverMockRecorder) Resolve(ID, resolveTime any) *gomock.Call { type MockVCR struct { ctrl *gomock.Controller recorder *MockVCRMockRecorder + isgomock struct{} } // MockVCRMockRecorder is the mock recorder for MockVCR. diff --git a/vcr/openid4vci/identifiers.go b/vcr/openid4vci/identifiers.go index ffacf3cb3c..37c2f4bed3 100644 --- a/vcr/openid4vci/identifiers.go +++ b/vcr/openid4vci/identifiers.go @@ -23,6 +23,7 @@ import ( "fmt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" @@ -143,12 +144,8 @@ func (t tlsIdentifierResolver) resolveFromCertificate(id did.DID) (string, error } // Resolve URLs - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.TLSClientConfig = t.config - httpClient := &http.Client{ - Timeout: 5 * time.Second, - Transport: httpTransport, - } + httpClient := client.NewWithTLSConfig(5*time.Second, t.config) + for _, candidateURL := range candidateURLs { issuerIdentifier := core.JoinURLPaths(candidateURL, "n2n", "identity", url.PathEscape(id.String())) err := t.testIdentifier(issuerIdentifier, httpClient) @@ -161,9 +158,13 @@ func (t tlsIdentifierResolver) resolveFromCertificate(id did.DID) (string, error return "", nil } -func (t tlsIdentifierResolver) testIdentifier(issuerIdentifier string, httpClient *http.Client) error { +func (t tlsIdentifierResolver) testIdentifier(issuerIdentifier string, httpClient core.HTTPRequestDoer) error { metadataURL := core.JoinURLPaths(issuerIdentifier, CredentialIssuerMetadataWellKnownPath) - httpResponse, err := httpClient.Head(metadataURL) + request, err := http.NewRequest(http.MethodHead, metadataURL, nil) + if err != nil { + return err + } + httpResponse, err := httpClient.Do(request) if err != nil { return err } diff --git a/vcr/openid4vci/identifiers_mock.go b/vcr/openid4vci/identifiers_mock.go index 9bb1b76c9a..6e06093d1e 100644 --- a/vcr/openid4vci/identifiers_mock.go +++ b/vcr/openid4vci/identifiers_mock.go @@ -20,6 +20,7 @@ import ( type MockIdentifierResolver struct { ctrl *gomock.Controller recorder *MockIdentifierResolverMockRecorder + isgomock struct{} } // MockIdentifierResolverMockRecorder is the mock recorder for MockIdentifierResolver. diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index 91ab1eafd9..c355aa96d5 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/log" + "io" "net/http" "net/http/httptrace" "net/url" @@ -167,7 +168,7 @@ func httpDo(httpClient core.HTTPRequestDoer, httpRequest *http.Request, result i return fmt.Errorf("http request error: %w", err) } defer httpResponse.Body.Close() - responseBody, err := core.LimitedReadAll(httpResponse.Body) + responseBody, err := io.ReadAll(httpResponse.Body) if err != nil { return fmt.Errorf("read error (%s): %w", httpRequest.URL, err) } diff --git a/vcr/openid4vci/issuer_client_mock.go b/vcr/openid4vci/issuer_client_mock.go index 1774ed253d..370f86f84e 100644 --- a/vcr/openid4vci/issuer_client_mock.go +++ b/vcr/openid4vci/issuer_client_mock.go @@ -22,6 +22,7 @@ import ( type MockIssuerAPIClient struct { ctrl *gomock.Controller recorder *MockIssuerAPIClientMockRecorder + isgomock struct{} } // MockIssuerAPIClientMockRecorder is the mock recorder for MockIssuerAPIClient. @@ -89,6 +90,7 @@ func (mr *MockIssuerAPIClientMockRecorder) RequestCredential(ctx, request, acces type MockOAuth2Client struct { ctrl *gomock.Controller recorder *MockOAuth2ClientMockRecorder + isgomock struct{} } // MockOAuth2ClientMockRecorder is the mock recorder for MockOAuth2Client. diff --git a/vcr/openid4vci/wallet_client_mock.go b/vcr/openid4vci/wallet_client_mock.go index b67b5cad6e..e33eb42f18 100644 --- a/vcr/openid4vci/wallet_client_mock.go +++ b/vcr/openid4vci/wallet_client_mock.go @@ -20,6 +20,7 @@ import ( type MockWalletAPIClient struct { ctrl *gomock.Controller recorder *MockWalletAPIClientMockRecorder + isgomock struct{} } // MockWalletAPIClientMockRecorder is the mock recorder for MockWalletAPIClient. diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index b3b27bfe37..df41b6907f 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -435,12 +435,12 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface } // if filter at path matches return true - match, err := matchFilter(*field.Filter, value) + match, matchedValue, err := matchFilter(*field.Filter, value) if err != nil { return false, nil, err } if match { - return true, value, nil + return true, matchedValue, nil } // if filter at path does not match continue and set optionalInvalid optionalInvalid++ @@ -466,14 +466,17 @@ func getValueAtPath(path string, vcAsInterface interface{}) (interface{}, error) return value, err } -// matchFilter matches the value against the filter. +// matchFilter matches the value against the filter. It returns true if the value matches the filter, along with the matched value. // A filter is a JSON Schema descriptor (https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-structural) // Supported schema types: string, number, boolean, array, enum. // Supported schema properties: const, enum, pattern. These only work for strings. // Supported go value types: string, float64, int, bool and array. // 'null' values are not supported. -// It returns an error on unsupported features or when the regex pattern fails. -func matchFilter(filter Filter, value interface{}) (bool, error) { +// It returns an error when; +// - an unsupported feature is used +// - the regex pattern fails +// - the regex pattern contains more than 1 capture group +func matchFilter(filter Filter, value interface{}) (bool, interface{}, error) { // first we check if it's an enum, so we can recursively call matchFilter for each value if filter.Enum != nil { for _, enum := range filter.Enum { @@ -481,62 +484,79 @@ func matchFilter(filter Filter, value interface{}) (bool, error) { Type: "string", Const: &enum, } - match, _ := matchFilter(f, value) + match, result, _ := matchFilter(f, value) if match { - return true, nil + return true, result, nil } } - return false, nil + return false, nil, nil } switch typedValue := value.(type) { case string: if filter.Type != "string" { - return false, nil + return false, nil, nil } case float64: if filter.Type != "number" { - return false, nil + return false, nil, nil } case int: if filter.Type != "number" { - return false, nil + return false, nil, nil } case bool: if filter.Type != "boolean" { - return false, nil + return false, nil, nil } case []interface{}: for _, v := range typedValue { - match, err := matchFilter(filter, v) + match, _, err := matchFilter(filter, v) if err != nil { - return false, err + return false, nil, err } if match { - return true, nil + return true, value, nil } } default: // object not supported for now - return false, ErrUnsupportedFilter + return false, nil, ErrUnsupportedFilter } if filter.Const != nil { if value != *filter.Const { - return false, nil + return false, nil, nil } } if filter.Pattern != nil && filter.Type == "string" { re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript) if err != nil { - return false, err + return false, nil, err + } + match, err := re.FindStringMatch(value.(string)) + if err != nil { + return false, nil, err + } + if match == nil { + return false, nil, nil + } + // We support returning a single capture group; + // - If there's a capture group, return it + // - If there's no capture group, return the whole match + // - If there's multiple capture groups, return an error + if len(match.Groups()) == 1 { + return true, string(match.Capture.Runes()), nil + } else if len(match.Groups()) == 2 { + return true, string(match.Groups()[1].Runes()), nil + } else { + return false, nil, errors.New("can't return results from multiple regex capture groups") } - return re.MatchString(value.(string)) } // if we get here, no pattern, enum or const is requested just the type. - return true, nil + return true, value, nil } // deduplicate removes duplicate VCs from the slice. diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index b1570239ee..902ad2dad2 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -25,6 +25,7 @@ import ( "embed" "encoding/json" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/core/to" vcrTest "github.com/nuts-foundation/nuts-node/vcr/test" "strings" "testing" @@ -761,9 +762,12 @@ func Test_matchFilter(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - got, err := matchFilter(testCase.filter, testCase.value) + matches, matchedValue, err := matchFilter(testCase.filter, testCase.value) require.NoError(t, err) - assert.Equal(t, testCase.want, got) + assert.Equal(t, testCase.want, matches) + if testCase.want && matches { + assert.Equal(t, testCase.value, matchedValue) + } }) } }) @@ -775,13 +779,46 @@ func Test_matchFilter(t *testing.T) { filters := []Filter{f1, f2, f3} t.Run("ok", func(t *testing.T) { for _, filter := range filters { - match, err := matchFilter(filter, stringValue) + match, matchedValue, err := matchFilter(filter, stringValue) require.NoError(t, err) assert.True(t, match) + assert.Equal(t, stringValue, matchedValue) } }) + t.Run("pattern", func(t *testing.T) { + t.Run("no match", func(t *testing.T) { + match, value, err := matchFilter(Filter{Type: "string", Pattern: to.Ptr("[0-9]+")}, "value") + require.NoError(t, err) + assert.Nil(t, value) + assert.False(t, match) + }) + t.Run("capture group", func(t *testing.T) { + match, value, err := matchFilter(Filter{Type: "string", Pattern: to.Ptr("v([a-z]+)e")}, "value") + require.NoError(t, err) + assert.Equal(t, "alu", value) + assert.True(t, match) + }) + t.Run("no capture group", func(t *testing.T) { + match, value, err := matchFilter(Filter{Type: "string", Pattern: to.Ptr("value")}, "value") + require.NoError(t, err) + assert.Equal(t, "value", value) + assert.True(t, match) + }) + t.Run("non-capturing group", func(t *testing.T) { + match, value, err := matchFilter(Filter{Type: "string", Pattern: to.Ptr("(?:val)ue")}, "value") + require.NoError(t, err) + assert.Equal(t, "value", value) + assert.True(t, match) + }) + t.Run("too many capture groups", func(t *testing.T) { + match, value, err := matchFilter(Filter{Type: "string", Pattern: to.Ptr("(v)(a)lue")}, "value") + require.EqualError(t, err, "can't return results from multiple regex capture groups") + assert.False(t, match) + assert.Nil(t, value) + }) + }) t.Run("enum value not found", func(t *testing.T) { - match, err := matchFilter(f2, "foo") + match, _, err := matchFilter(f2, "foo") require.NoError(t, err) assert.False(t, match) }) @@ -790,17 +827,17 @@ func Test_matchFilter(t *testing.T) { t.Run("error cases", func(t *testing.T) { t.Run("enum with wrong type", func(t *testing.T) { f := Filter{Type: "object"} - match, err := matchFilter(f, struct{}{}) + match, _, err := matchFilter(f, struct{}{}) assert.False(t, match) assert.Equal(t, err, ErrUnsupportedFilter) }) t.Run("incorrect regex", func(t *testing.T) { pattern := "[" f := Filter{Type: "string", Pattern: &pattern} - match, err := matchFilter(f, stringValue) + match, _, err := matchFilter(f, stringValue) assert.False(t, match) assert.Error(t, err, "error parsing regexp: missing closing ]: `[`") - match, err = matchFilter(f, []interface{}{stringValue}) + match, _, err = matchFilter(f, []interface{}{stringValue}) assert.False(t, match) assert.Error(t, err, "error parsing regexp: missing closing ]: `[`") }) diff --git a/vcr/revocation/bitstring.go b/vcr/revocation/bitstring.go index 9529876f71..952a897c2e 100644 --- a/vcr/revocation/bitstring.go +++ b/vcr/revocation/bitstring.go @@ -54,9 +54,14 @@ func (bs *bitstring) Scan(value any) error { *bs = nil return nil } - asString, ok := value.(string) - if !ok { - return fmt.Errorf("bitstring unmarshal from DB: expected []uint8, got %T", value) + var asString string + switch v := value.(type) { + case string: // sqlite, postgress, sqlserver + asString = v + case []uint8: // mysql + asString = string(v) + default: + return fmt.Errorf("bitstring unmarshal from DB: expected []uint8 or string, got %T", value) } expanded, err := expand(asString) if err != nil { diff --git a/vcr/revocation/bitstring_test.go b/vcr/revocation/bitstring_test.go index 2de832f321..2af0dd08aa 100644 --- a/vcr/revocation/bitstring_test.go +++ b/vcr/revocation/bitstring_test.go @@ -20,7 +20,7 @@ package revocation import ( "database/sql" - "github.com/glebarez/sqlite" + "github.com/nuts-foundation/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "math/rand" diff --git a/vcr/revocation/statuslist2021_issuer.go b/vcr/revocation/statuslist2021_issuer.go index d8946eb1bc..6588deecd6 100644 --- a/vcr/revocation/statuslist2021_issuer.go +++ b/vcr/revocation/statuslist2021_issuer.go @@ -118,12 +118,13 @@ func (cs *StatusList2021) loadCredential(subjectID string) (*credentialRecord, e // isManaged returns true if the StatusList2021Credential is issued by this node. // returns false on db errors, or if the StatusList2021Credential does not exist. func (cs *StatusList2021) isManaged(subjectID string) bool { - var exists bool + var count int cs.db.Model(new(credentialIssuerRecord)). - Select("count(*) > 0"). + Select("count(*)"). + Group("subject_id"). Where("subject_id = ?", subjectID). - First(&exists) - return exists + Find(&count) + return count > 0 } func (cs *StatusList2021) Credential(ctx context.Context, issuerDID did.DID, page int) (*vc.VerifiableCredential, error) { @@ -165,18 +166,7 @@ func (cs *StatusList2021) Credential(ctx context.Context, issuerDID did.DID, pag var cred *vc.VerifiableCredential // is nil, so if this panics outside this method the var name is probably shadowed in the db.Transaction. err = cs.db.Transaction(func(tx *gorm.DB) error { // lock credentialRecord row for statusListCredentialURL since it will be updated. - // Revoke does the same to guarantee the DB always contains all revocations. - // Microsoft SQL server does not support the locking clause, so we have to use a raw query instead. - // See https://github.com/nuts-foundation/nuts-node/issues/3393 - if tx.Dialector.Name() == "sqlserver" { - err = tx.Raw("SELECT * FROM status_list_entry WITH (UPDLOCK, ROWLOCK) WHERE subject_id = ?", statusListCredentialURL). - Scan(new(credentialRecord)). - Error - } else { - err = tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). - Find(new(credentialRecord), "subject_id = ?", statusListCredentialURL). - Error - } + err = lockCredentialRecord(tx, statusListCredentialURL) if err != nil { return err } @@ -420,12 +410,7 @@ func (cs *StatusList2021) Revoke(ctx context.Context, credentialID ssi.URI, entr return cs.db.Transaction(func(tx *gorm.DB) error { // lock relevant credentialRecord. It was created when the first entry was issued for this StatusList2021Credential. - err = tx.Model(new(credentialRecord)). - Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). - Select("count(*) > 0"). - Where("subject_id = ?", entry.StatusListCredential). - First(new(bool)). - Error + err = lockCredentialRecord(tx, entry.StatusListCredential) if err != nil { return err } @@ -477,3 +462,16 @@ func (cs *StatusList2021) statusListURL(issuer did.DID, page int) string { result, _ := url.Parse(cs.baseURL) return result.JoinPath("statuslist", issuer.String(), strconv.Itoa(page)).String() } + +func lockCredentialRecord(tx *gorm.DB, statusListCredentialURL string) error { + // Microsoft SQL server does not support the locking clause, so we have to use a raw query instead. + // See https://github.com/nuts-foundation/nuts-node/issues/3393 + if tx.Dialector.Name() == "sqlserver" { + return tx.Raw("SELECT * FROM status_list_credential WITH (UPDLOCK, ROWLOCK) WHERE subject_id = ?", statusListCredentialURL). + Scan(new(credentialRecord)). + Error + } + return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). + Find(new(credentialRecord), "subject_id = ?", statusListCredentialURL). + Error +} diff --git a/vcr/revocation/statuslist2021_verifier.go b/vcr/revocation/statuslist2021_verifier.go index a1eeb89a21..4071ac54cd 100644 --- a/vcr/revocation/statuslist2021_verifier.go +++ b/vcr/revocation/statuslist2021_verifier.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strconv" "time" @@ -198,7 +199,7 @@ func (cs *StatusList2021) download(statusListCredential string) (*vc.VerifiableC Debug("Failed to close response body") } }() - body, err := core.LimitedReadAll(res.Body) // default minimum size is 16kb (PII entropy), so 1mb is already unlikely + body, err := io.ReadAll(res.Body) if res.StatusCode > 299 || err != nil { return nil, errors.Join(fmt.Errorf("fetching StatusList2021Credential from '%s' failed", statusListCredential), err) } diff --git a/vcr/signature/mock.go b/vcr/signature/mock.go index 2fad7501ce..a76d0f0ad2 100644 --- a/vcr/signature/mock.go +++ b/vcr/signature/mock.go @@ -21,6 +21,7 @@ import ( type MockSuite struct { ctrl *gomock.Controller recorder *MockSuiteMockRecorder + isgomock struct{} } // MockSuiteMockRecorder is the mock recorder for MockSuite. diff --git a/vcr/test.go b/vcr/test.go index 906fe94f89..0dd2436a02 100644 --- a/vcr/test.go +++ b/vcr/test.go @@ -66,7 +66,6 @@ func NewTestVCRContext(t *testing.T, keyStore crypto.KeyStore) TestVCRContext { ctrl := gomock.NewController(t) pkiMock := pki.NewMockValidator(ctrl) vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager, storageEngine, pkiMock) - vdrInstance.Config().(*vdr.Config).DIDMethods = []string{"web", "nuts"} err := vdrInstance.Configure(core.TestServerConfig()) require.NoError(t, err) newInstance := NewVCRInstance( @@ -109,7 +108,6 @@ func NewTestVCRInstance(t *testing.T) *vcr { ctrl := gomock.NewController(t) pkiMock := pki.NewMockValidator(ctrl) vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager, storageEngine, pkiMock) - vdrInstance.Config().(*vdr.Config).DIDMethods = []string{"web", "nuts"} err := vdrInstance.Configure(serverCfg) if err != nil { t.Fatal(err) @@ -141,7 +139,6 @@ func NewTestVCRInstanceInDir(t *testing.T, testDirectory string) *vcr { ctrl := gomock.NewController(t) pkiMock := pki.NewMockValidator(ctrl) vdrInstance := vdr.NewVDR(nil, networkInstance, didStore, eventManager, storageEngine, pkiMock) - vdrInstance.Config().(*vdr.Config).DIDMethods = []string{"web", "nuts"} err := vdrInstance.Configure(core.TestServerConfig()) if err != nil { t.Fatal(err) @@ -185,7 +182,7 @@ func newMockContext(t *testing.T) mockContext { tx.EXPECT().WithPersistency().AnyTimes() tx.EXPECT().Subscribe("vcr_vcs", gomock.Any(), gomock.Any()) tx.EXPECT().Subscribe("vcr_revocations", gomock.Any(), gomock.Any()) - tx.EXPECT().CleanupSubscriberEvents("vcr_vcs", gomock.Any()) + tx.EXPECT().Disabled().AnyTimes() didResolver := resolver.NewMockDIDResolver(ctrl) documentOwner := didsubject.NewMockDocumentOwner(ctrl) vdrInstance := vdr.NewMockVDR(ctrl) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index 769d8e12be..3e90ed0761 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -23,7 +23,6 @@ import ( "encoding/json" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/jsonld" - "github.com/nuts-foundation/nuts-node/network/log" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/didsubject" @@ -31,9 +30,7 @@ import ( "github.com/stretchr/testify/assert" "io" "net/http" - "net/http/httptrace" "net/url" - "sync" "testing" "time" @@ -80,79 +77,6 @@ func TestOpenID4VCIHappyFlow(t *testing.T) { }, 5*time.Second, "credential not retrieved by holder") } -func TestOpenID4VCIConnectionReuse(t *testing.T) { - // default http.Transport has MaxConnsPerHost=100, - // but we need to adjust it to something lower, so we can assert connection reuse - const maxConnsPerHost = 2 - // for 2 http.Transport instance (one for issuer, one for wallet), - // so we expect max maxConnsPerHost*2 connections in total. - const maxExpectedConnCount = maxConnsPerHost * 2 - http.DefaultTransport.(*http.Transport).MaxConnsPerHost = maxConnsPerHost - - ctx := audit.TestContext() - _, baseURL, system := node.StartServer(t) - vcrService := system.FindEngineByName("vcr").(vcr.VCR) - - issuerDID := registerDID(t, system) - registerBaseURL(t, baseURL, system, issuerDID) - holderDID := registerDID(t, system) - registerBaseURL(t, baseURL, system, holderDID) - - credential := testCredential() - credential.Issuer = issuerDID.URI() - credential.ID, _ = ssi.ParseURI(issuerDID.URI().String() + "#1") - credential.CredentialSubject = append(credential.CredentialSubject, map[string]interface{}{ - "id": holderDID.URI().String(), - "purposeOfUse": "test", - }) - - newConns := map[string]int{} - mux := sync.Mutex{} - openid4vci.HttpClientTrace = &httptrace.ClientTrace{ - ConnectStart: func(network, addr string) { - log.Logger().Infof("Conn: %s/%s", network, addr) - mux.Lock() - defer mux.Unlock() - newConns[network+"/"+addr]++ - }, - } - - const numCreds = 10 - errChan := make(chan error, numCreds) - wg := sync.WaitGroup{} - for i := 0; i < numCreds; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _, err := vcrService.Issuer().Issue(ctx, credential, issuer.CredentialOptions{ - Publish: true, - Public: false, - }) - if err != nil { - errChan <- err - return - } - }() - } - - wg.Wait() - // Drain errs channel, non-blocking - close(errChan) - var errs []string - for { - err := <-errChan - if err == nil { - break - - } - errs = append(errs, err.Error()) - } - assert.Empty(t, errs, "error issuing credential") - for host, v := range newConns { - assert.LessOrEqualf(t, v, maxExpectedConnCount, "number of created HTTP connections should be at most %d for host %s", maxConnsPerHost, host) - } -} - // TestOpenID4VCIDisabled tests the issuer won't try to issue over OpenID4VCI when it's disabled. func TestOpenID4VCIDisabled(t *testing.T) { _, baseURL, system := node.StartServer(t, func(_, _ string) { diff --git a/vcr/types/mock.go b/vcr/types/mock.go index 7c0785d395..9a75337765 100644 --- a/vcr/types/mock.go +++ b/vcr/types/mock.go @@ -21,6 +21,7 @@ import ( type MockWriter struct { ctrl *gomock.Controller recorder *MockWriterMockRecorder + isgomock struct{} } // MockWriterMockRecorder is the mock recorder for MockWriter. diff --git a/vcr/vcr.go b/vcr/vcr.go index 59400e5e02..ac4df93d20 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -203,7 +203,6 @@ func (c *vcr) Configure(config core.ServerConfig) error { c.keyResolver = resolver.DIDKeyResolver{Resolver: didResolver} c.serviceResolver = resolver.DIDServiceResolver{Resolver: didResolver} - networkPublisher := issuer.NewNetworkPublisher(c.network, didResolver, c.keyStore) tlsConfig, err := c.pkiProvider.CreateTLSConfig(config.TLS) // returns nil if TLS is disabled if err != nil { return err @@ -225,11 +224,18 @@ func (c *vcr) Configure(config core.ServerConfig) error { c.openidSessionStore = c.storageClient.GetSessionDatabase() } + var networkPublisher issuer.Publisher + if !c.network.Disabled() { + networkPublisher = issuer.NewNetworkPublisher(c.network, didResolver, c.keyStore) + } + status := revocation.NewStatusList2021(c.storageClient.GetSQLDatabase(), client.NewWithCache(config.HTTPClient.Timeout), config.URL) c.issuer = issuer.NewIssuer(c.issuerStore, c, networkPublisher, openidHandlerFn, didResolver, c.keyStore, c.jsonldManager, c.trustConfig, status) c.verifier = verifier.NewVerifier(c.verifierStore, didResolver, c.keyResolver, c.jsonldManager, c.trustConfig, status) - c.ambassador = NewAmbassador(c.network, c, c.verifier, c.eventManager) + if !c.network.Disabled() { + c.ambassador = NewAmbassador(c.network, c, c.verifier, c.eventManager) + } // Create holder/wallet c.wallet = holder.NewSQLWallet(c.keyResolver, c.keyStore, c.verifier, c.jsonldManager, c.storageClient) @@ -269,6 +275,9 @@ func (c *vcr) createCredentialsStore() error { } func (c *vcr) Start() error { + if c.ambassador == nil { // did:nuts / network layer is disabled + return nil + } // start listening for new credentials _ = c.ambassador.Configure() diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index 2669ecf5f0..195b99ccd6 100644 --- a/vcr/vcr_test.go +++ b/vcr/vcr_test.go @@ -73,7 +73,9 @@ func TestVCR_Configure(t *testing.T) { vdrInstance.EXPECT().Resolver().AnyTimes() pkiProvider := pki.NewMockProvider(ctrl) pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes() - instance := NewVCRInstance(nil, vdrInstance, nil, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) + networkInstance := network.NewMockTransactions(ctrl) + networkInstance.EXPECT().Disabled().AnyTimes() + instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr) instance.config.OpenID4VCI.Enabled = true err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) { diff --git a/vcr/verifier/mock.go b/vcr/verifier/mock.go index 5979a7bf0e..59b2c7bcf2 100644 --- a/vcr/verifier/mock.go +++ b/vcr/verifier/mock.go @@ -24,6 +24,7 @@ import ( type MockVerifier struct { ctrl *gomock.Controller recorder *MockVerifierMockRecorder + isgomock struct{} } // MockVerifierMockRecorder is the mock recorder for MockVerifier. @@ -134,6 +135,7 @@ func (mr *MockVerifierMockRecorder) VerifyVP(presentation, verifyVCs, allowUntru type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder + isgomock struct{} } // MockStoreMockRecorder is the mock recorder for MockStore. diff --git a/vcr/verifier/signature_verifier.go b/vcr/verifier/signature_verifier.go index e5d1b76cf9..402fc57e07 100644 --- a/vcr/verifier/signature_verifier.go +++ b/vcr/verifier/signature_verifier.go @@ -87,6 +87,9 @@ func (sv *signatureVerifier) jsonldProof(documentToVerify any, issuer string, at // for a VP this will not fail verificationMethod := ldProof.VerificationMethod.String() + if verificationMethod == "" { + return newVerificationError("missing proof") + } verificationMethodIssuer := strings.Split(verificationMethod, "#")[0] if verificationMethodIssuer == "" || verificationMethodIssuer != issuer { return errVerificationMethodNotOfIssuer diff --git a/vcr/verifier/signature_verifier_test.go b/vcr/verifier/signature_verifier_test.go index 2d7dd468fe..a2a5742a15 100644 --- a/vcr/verifier/signature_verifier_test.go +++ b/vcr/verifier/signature_verifier_test.go @@ -269,7 +269,7 @@ func TestSignatureVerifier_VerifySignature(t *testing.T) { err := sv.VerifySignature(vc2, nil) - assert.EqualError(t, err, "verification error: unsupported proof type: json: cannot unmarshal array into Go value of type proof.LDProof") + assert.EqualError(t, err, "verification error: missing proof") }) t.Run("error - wrong jws in proof", func(t *testing.T) { diff --git a/vdr/api/v1/client.go b/vdr/api/v1/client.go index 93db80fb60..66f76eeefe 100644 --- a/vdr/api/v1/client.go +++ b/vdr/api/v1/client.go @@ -36,7 +36,7 @@ type HTTPClient struct { } func (hb HTTPClient) client() ClientInterface { - response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateHTTPClient(hb.ClientConfig, hb.TokenGenerator))) + response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateInternalHTTPClient(hb.ClientConfig, hb.TokenGenerator))) if err != nil { panic(err) } diff --git a/vdr/api/v1/generated.go b/vdr/api/v1/generated.go index 69fb5b251c..73a244f36f 100644 --- a/vdr/api/v1/generated.go +++ b/vdr/api/v1/generated.go @@ -1,6 +1,6 @@ // Package v1 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v1 import ( diff --git a/vdr/api/v2/api.go b/vdr/api/v2/api.go index cb68f04bc0..7b330ce919 100644 --- a/vdr/api/v2/api.go +++ b/vdr/api/v2/api.go @@ -56,13 +56,18 @@ type Wrapper struct { // ResolveStatusCode maps errors returned by this API to specific HTTP status codes. func (w *Wrapper) ResolveStatusCode(err error) int { return core.ResolveStatusCode(err, map[error]int{ - didsubject.ErrSubjectNotFound: http.StatusNotFound, - didsubject.ErrSubjectAlreadyExists: http.StatusConflict, - resolver.ErrNotFound: http.StatusNotFound, - resolver.ErrDIDNotManagedByThisNode: http.StatusForbidden, - did.ErrInvalidDID: http.StatusBadRequest, - didsubject.ErrInvalidService: http.StatusBadRequest, - didsubject.ErrUnsupportedDIDMethod: http.StatusBadRequest, + didsubject.ErrSubjectNotFound: http.StatusNotFound, + didsubject.ErrSubjectAlreadyExists: http.StatusConflict, + resolver.ErrNotFound: http.StatusNotFound, + resolver.ErrDIDNotManagedByThisNode: http.StatusForbidden, + did.ErrInvalidDID: http.StatusBadRequest, + didsubject.ErrInvalidService: http.StatusBadRequest, + didsubject.ErrUnsupportedDIDMethod: http.StatusBadRequest, + didsubject.ErrKeyAgreementNotSupported: http.StatusBadRequest, + didsubject.ErrSubjectValidation: http.StatusBadRequest, + resolver.ErrDeactivated: http.StatusConflict, + did.ErrInvalidService: http.StatusBadRequest, + resolver.ErrDuplicateService: http.StatusConflict, }) } @@ -121,7 +126,6 @@ func (w *Wrapper) CreateSubject(ctx context.Context, request CreateSubjectReques } docs, subject, err := w.SubjectManager.Create(ctx, options) - // if this operation leads to an error, it may return a 500 if err != nil { return nil, err } diff --git a/vdr/api/v2/client.go b/vdr/api/v2/client.go index 2d944f0443..4fd3b7b0f2 100644 --- a/vdr/api/v2/client.go +++ b/vdr/api/v2/client.go @@ -34,7 +34,7 @@ type HTTPClient struct { } func (hb HTTPClient) client() ClientInterface { - response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateHTTPClient(hb.ClientConfig, hb.TokenGenerator))) + response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateInternalHTTPClient(hb.ClientConfig, hb.TokenGenerator))) if err != nil { panic(err) } diff --git a/vdr/api/v2/generated.go b/vdr/api/v2/generated.go index 81ac871c4e..11a3fd99f5 100644 --- a/vdr/api/v2/generated.go +++ b/vdr/api/v2/generated.go @@ -1,6 +1,6 @@ // Package v2 provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package v2 import ( @@ -35,6 +35,7 @@ type CreateSubjectOptions struct { Keys *KeyCreationOptions `json:"keys,omitempty"` // Subject controls the DID subject to which all created DIDs are bound. If not given, a uuid is generated and returned. + // The subject must follow the pattern [a-zA-Z0-9._-]+ Subject *string `json:"subject,omitempty"` } diff --git a/vdr/cmd/cmd.go b/vdr/cmd/cmd.go index da0c7d857f..5fad559902 100644 --- a/vdr/cmd/cmd.go +++ b/vdr/cmd/cmd.go @@ -30,26 +30,13 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/vdr" api "github.com/nuts-foundation/nuts-node/vdr/api/v1" apiv2 "github.com/nuts-foundation/nuts-node/vdr/api/v2" "github.com/nuts-foundation/nuts-node/vdr/didnuts" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) -// FlagSet contains flags relevant for the VDR instance -func FlagSet() *pflag.FlagSet { - flagSet := pflag.NewFlagSet("vdr", pflag.ContinueOnError) - - defs := vdr.DefaultConfig() - - flagSet.StringSlice("vdr.didmethods", defs.DIDMethods, "Comma-separated list of enabled DID methods (without did: prefix). "+ - "It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used.") - return flagSet -} - // Cmd contains sub-commands for the remote client func Cmd() *cobra.Command { cmd := &cobra.Command{ diff --git a/vdr/cmd/cmd_test.go b/vdr/cmd/cmd_test.go index 2424f615a4..ab8f7ef82a 100644 --- a/vdr/cmd/cmd_test.go +++ b/vdr/cmd/cmd_test.go @@ -40,10 +40,6 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_flagSet(t *testing.T) { - assert.NotNil(t, FlagSet()) -} - func TestEngine_Command(t *testing.T) { exampleID, _ := did.ParseDID("did:nuts:Fx8kamg7Bom4gyEzmJc9t9QmWTkCwSxu3mrp3CbkehR7") exampleDIDDocument := did.Document{ diff --git a/vdr/config.go b/vdr/config.go deleted file mode 100644 index 4f1123f333..0000000000 --- a/vdr/config.go +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 vdr - -type Config struct { - DIDMethods []string `koanf:"didmethods"` -} - -func DefaultConfig() Config { - return Config{ - DIDMethods: []string{"web", "nuts"}, - } -} diff --git a/vdr/didnuts/ambassador_mock.go b/vdr/didnuts/ambassador_mock.go index 9553bffa18..0e6350a1b3 100644 --- a/vdr/didnuts/ambassador_mock.go +++ b/vdr/didnuts/ambassador_mock.go @@ -19,6 +19,7 @@ import ( type MockAmbassador struct { ctrl *gomock.Controller recorder *MockAmbassadorMockRecorder + isgomock struct{} } // MockAmbassadorMockRecorder is the mock recorder for MockAmbassador. diff --git a/vdr/didnuts/didstore/interface.go b/vdr/didnuts/didstore/interface.go index 22ee14cc68..d180c7741b 100644 --- a/vdr/didnuts/didstore/interface.go +++ b/vdr/didnuts/didstore/interface.go @@ -20,6 +20,7 @@ package didstore import ( "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -42,6 +43,11 @@ type Store interface { // It returns vdr.ErrNotFound if there are no corresponding DID documents or when the DID Documents are disjoint with the provided ResolveMetadata. // It returns vdr.ErrDeactivated if no metadata is given and the latest version of the DID Document is deactivated. Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did.Document, *resolver.DocumentMetadata, error) + // HistorySinceVersion returns all versions of the DID Document since the specified version (including version). + // The slice is empty when version is the most recent version of the DID Document. + // The history contains DID Documents as they were published, which differs from Resolve that produces a merge of conflicted documents. + // DEPRECATED: This function exists to migrate the history of owned DIDs from key-value storage to SQL storage. + HistorySinceVersion(id did.DID, version int) ([]orm.MigrationDocument, error) } // Transaction is an alias to the didstore.event. Internally to the didstore it's an event based on a transaction. diff --git a/vdr/didnuts/didstore/mock.go b/vdr/didnuts/didstore/mock.go index 9991b42f16..ffd4e66d94 100644 --- a/vdr/didnuts/didstore/mock.go +++ b/vdr/didnuts/didstore/mock.go @@ -13,6 +13,7 @@ import ( reflect "reflect" did "github.com/nuts-foundation/go-did/did" + orm "github.com/nuts-foundation/nuts-node/storage/orm" resolver "github.com/nuts-foundation/nuts-node/vdr/resolver" gomock "go.uber.org/mock/gomock" ) @@ -21,6 +22,7 @@ import ( type MockStore struct { ctrl *gomock.Controller recorder *MockStoreMockRecorder + isgomock struct{} } // MockStoreMockRecorder is the mock recorder for MockStore. @@ -98,6 +100,21 @@ func (mr *MockStoreMockRecorder) DocumentCount() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DocumentCount", reflect.TypeOf((*MockStore)(nil).DocumentCount)) } +// HistorySinceVersion mocks base method. +func (m *MockStore) HistorySinceVersion(id did.DID, version int) ([]orm.MigrationDocument, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HistorySinceVersion", id, version) + ret0, _ := ret[0].([]orm.MigrationDocument) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HistorySinceVersion indicates an expected call of HistorySinceVersion. +func (mr *MockStoreMockRecorder) HistorySinceVersion(id, version any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HistorySinceVersion", reflect.TypeOf((*MockStore)(nil).HistorySinceVersion), id, version) +} + // Iterate mocks base method. func (m *MockStore) Iterate(fn resolver.DocIterator) error { m.ctrl.T.Helper() diff --git a/vdr/didnuts/didstore/store.go b/vdr/didnuts/didstore/store.go index 5b2d44c138..389062e916 100644 --- a/vdr/didnuts/didstore/store.go +++ b/vdr/didnuts/didstore/store.go @@ -26,7 +26,10 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/hash" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/vdr/log" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -349,3 +352,60 @@ func latestNonDeactivatedRequested(resolveMetadata *resolver.ResolveMetadata) bo } return !resolveMetadata.AllowDeactivated } + +func (tl *store) HistorySinceVersion(id did.DID, version int) ([]orm.MigrationDocument, error) { + if version < 0 { + return nil, errors.New("negative version") + } + var history []orm.MigrationDocument + txErr := tl.db.Read(context.TODO(), func(tx stoabs.ReadTx) error { + el, err := readEventList(tx, id) + if err != nil { + return err + } + if len(el.Events) == 0 { + return storage.ErrNotFound + } + highestDIDStoreVersion := len(el.Events) - 1 + + // return if no changes + if version > highestDIDStoreVersion { + return nil + } + + created := el.Events[0].SigningTime + documentReader := tx.GetShelfReader(documentShelf) + history = make([]orm.MigrationDocument, 0, highestDIDStoreVersion-version+1) + for v := version; v <= highestDIDStoreVersion; v++ { + payloadHash := el.Events[v].PayloadHash + documentBytes, err := documentReader.Get(stoabs.NewHashKey(payloadHash)) + if err != nil { + if errors.Is(err, stoabs.ErrKeyNotFound) { + return storage.ErrNotFound + } + return err + } + // We expect documentBytes to be equal to the raw payload on the DAG because we are the publisher of the document and use the same un/marshal methods everywhere. + // This will break if did.Document in go-did is changed. + // Confirmed this is safe/true for all documents on prd network as of 13-09-24, so for now just log if they are not the same. + if !payloadHash.Equals(hash.SHA256Sum(documentBytes)) { + log.Logger(). + WithField("payload hash", payloadHash). + WithField("document hash", hash.SHA256Sum(documentBytes)). + WithField("document version", version). + Error("Payload hash does not match hash of DID Document during did:nuts history migration") + } + history = append(history, orm.MigrationDocument{ + Raw: documentBytes, + Created: created, + Updated: el.Events[v].SigningTime, + Version: v, + }) + } + return nil + }) + if txErr != nil { + return nil, txErr + } + return history, nil +} diff --git a/vdr/didnuts/didstore/store_test.go b/vdr/didnuts/didstore/store_test.go index d3d42503e7..bcf0f726fd 100644 --- a/vdr/didnuts/didstore/store_test.go +++ b/vdr/didnuts/didstore/store_test.go @@ -29,6 +29,7 @@ import ( "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/go-stoabs/redis7" "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -454,3 +455,77 @@ func Test_matches(t *testing.T) { }) }) } + +func TestStore_HistorySinceVersion(t *testing.T) { + store := NewTestStore(t) + + // create DID document with some updates and a document conflict + doc1 := did.Document{ID: testDID, Controller: []did.DID{testDID}, Service: []did.Service{testServiceA}} + tx1 := newTestTransaction(doc1) // serviceA + tx1.SigningTime = time.Now().Add(-time.Second) + doc2a := did.Document{ID: testDID, Service: []did.Service{testServiceA}} + tx2a := newTestTransaction(doc2a, tx1.Ref) // deactivate + doc2b := did.Document{ID: testDID, Controller: []did.DID{testDID}, Service: []did.Service{testServiceB}} + tx2b := newTestTransaction(doc2b, tx1.Ref) // serviceB + doc3 := did.Document{ID: testDID, Controller: []did.DID{testDID}, Service: []did.Service{testServiceA, testServiceB}} + tx3 := newTestTransaction(doc3, tx2a.Ref, tx2b.Ref) // serviceA + serviceB + + // add all transactions. + // txs all have LC=0, so they are sorted on SigningTime. Nano-sec timestamps guarantee that the tx{1,2a,2b,3} order is preserved + require.NoError(t, store.Add(doc1, tx1)) + require.NoError(t, store.Add(doc2a, tx2a)) + require.NoError(t, store.Add(doc2b, tx2b)) + require.NoError(t, store.Add(doc3, tx3)) + + // raw documents in order + raw := [4][]byte{} + raw[0], _ = json.Marshal(doc1) + raw[1], _ = json.Marshal(doc2a) + raw[2], _ = json.Marshal(doc2b) + raw[3], _ = json.Marshal(doc3) + + t.Run("ok - full history", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, 0) + assert.NoError(t, err) + require.Len(t, history, 4) + + for idx, tx := range []Transaction{tx1, tx2a, tx2b, tx3} { + result := history[idx] + assert.Equal(t, raw[idx], result.Raw) // make sure result.Raw contains the original documents, not the merged document conflicts + assert.True(t, tx1.SigningTime.Equal(result.Created)) + assert.True(t, tx.SigningTime.Equal(result.Updated)) + assert.Equal(t, idx, result.Version) + } + }) + t.Run("ok - partial history", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, 1) + assert.NoError(t, err) + require.Len(t, history, 3) + assert.Equal(t, 1, history[0].Version) + }) + t.Run("ok - no version updates", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, 5) + assert.NoError(t, err) + assert.Len(t, history, 0) + }) + t.Run("error - negative version", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, -1) + assert.EqualError(t, err, "negative version") + assert.Nil(t, history) + }) + t.Run("error - unknown DID", func(t *testing.T) { + history, err := store.HistorySinceVersion(did.MustParseDID("did:nuts:unknown"), 0) + assert.ErrorIs(t, err, storage.ErrNotFound) + assert.Nil(t, history) + }) + t.Run("error - document version not found", func(t *testing.T) { + err := store.db.WriteShelf(context.Background(), documentShelf, func(writer stoabs.Writer) error { + return writer.Delete(stoabs.NewHashKey(tx1.PayloadHash)) + }) + require.NoError(t, err) + + history, err := store.HistorySinceVersion(testDID, 0) + assert.ErrorIs(t, err, storage.ErrNotFound) + assert.Nil(t, history) + }) +} diff --git a/vdr/didsubject/did_document.go b/vdr/didsubject/did_document.go index f8b283c2ca..3a15797568 100644 --- a/vdr/didsubject/did_document.go +++ b/vdr/didsubject/did_document.go @@ -88,5 +88,5 @@ func (s *SqlDIDDocumentManager) Latest(did did.DID, resolveTime *time.Time) (*or if err != nil { return nil, err } - return &doc, err + return &doc, nil } diff --git a/vdr/didsubject/interface.go b/vdr/didsubject/interface.go index fe5e28abbe..7d1d0fec3a 100644 --- a/vdr/didsubject/interface.go +++ b/vdr/didsubject/interface.go @@ -33,6 +33,18 @@ var ErrInvalidService = errors.New("invalid DID document service") // ErrUnsupportedDIDMethod is returned when a DID method is not supported. var ErrUnsupportedDIDMethod = errors.New("unsupported DID method") +// ErrKeyAgreementNotSupported is returned key agreement is required for did:web. +var ErrKeyAgreementNotSupported = errors.New("key agreement not supported for did:web") + +// ErrSubjectValidation is returned when the subject creation request is invalid. +var ErrSubjectValidation = errors.New("subject creation validation error") + +// ErrSubjectAlreadyExists is returned when a subject already exists. +var ErrSubjectAlreadyExists = errors.New("subject already exists") + +// ErrSubjectNotFound is returned when a subject is not found. +var ErrSubjectNotFound = errors.New("subject not found") + // MethodManager keeps DID method specific state in sync with the DID sql database. type MethodManager interface { // NewDocument generates a new DID document for the given subject. @@ -123,6 +135,17 @@ type Manager interface { Rollback(ctx context.Context) } +// DocumentMigration is used to migrate DID document versions to the SQL DB. This should only be used for DID documents managed by this node. +type DocumentMigration interface { + // MigrateDIDHistoryToSQL is used to migrate the history of a DID Document to SQL. + // It adds all versions of a DID Document up to a deactivated version. Any changes after a deactivation are not migrated. + // getHistory retrieves the history of the DID since the requested version. + MigrateDIDHistoryToSQL(id did.DID, subject string, getHistory func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error)) error + // MigrateAddWebToNuts checks if the provided did:nuts DID adds a did:web DID under the same subject if does not already have one. + // It does not check that id is a did:nuts DID + MigrateAddWebToNuts(ctx context.Context, id did.DID) error +} + // SubjectCreationOption links all create DIDs to the DID Subject type SubjectCreationOption struct { Subject string diff --git a/vdr/didsubject/manager.go b/vdr/didsubject/manager.go index ebdd953772..41608c9b28 100644 --- a/vdr/didsubject/manager.go +++ b/vdr/didsubject/manager.go @@ -33,6 +33,7 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/vdr/log" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "gorm.io/gorm" "regexp" "sort" @@ -40,14 +41,8 @@ import ( "time" ) -// ErrSubjectAlreadyExists is returned when a subject already exists. -var ErrSubjectAlreadyExists = errors.New("subject already exists") - -// ErrSubjectNotFound is returned when a subject is not found. -var ErrSubjectNotFound = errors.New("subject not found") - // subjectPattern is a regular expression for checking whether a subject follows the allowed pattern; a-z, 0-9, -, _, . (case insensitive) -var subjectPattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`) +var subjectPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) var _ Manager = (*SqlManager)(nil) @@ -125,7 +120,7 @@ func (r *SqlManager) Create(ctx context.Context, options CreationOptions) ([]did switch opt := option.(type) { case SubjectCreationOption: if !subjectPattern.MatchString(opt.Subject) { - return nil, "", fmt.Errorf("invalid subject (must follow pattern: %s)", subjectPattern.String()) + return nil, "", errors.Join(ErrSubjectValidation, fmt.Errorf("invalid subject (must follow pattern: %s)", subjectPattern.String())) } subject = opt.Subject case EncryptionKeyCreationOption: @@ -133,15 +128,14 @@ func (r *SqlManager) Create(ctx context.Context, options CreationOptions) ([]did case NutsLegacyNamingOption: nutsLegacy = true default: - return nil, "", fmt.Errorf("unknown option: %T", option) + return nil, "", errors.Join(ErrSubjectValidation, fmt.Errorf("unknown option: %T", option)) } } sqlDocs := make(map[string]orm.DidDocument) err := r.transactionHelper(ctx, func(tx *gorm.DB) (map[string]orm.DIDChangeLog, error) { // check existence - sqlDIDManager := NewDIDManager(tx) - _, err := sqlDIDManager.FindBySubject(subject) + _, err := NewDIDManager(tx).FindBySubject(subject) if errors.Is(err, ErrSubjectNotFound) { // this is ok, doesn't exist yet } else if err != nil { @@ -153,6 +147,12 @@ func (r *SqlManager) Create(ctx context.Context, options CreationOptions) ([]did // call generate on all managers for method, manager := range r.MethodManagers { + // known limitation, check is also done within the manager, but at this point we can return a known error for the API + // requires update to nutsCrypto module + if keyFlags.Is(orm.KeyAgreementUsage) && method == "web" { + return nil, ErrKeyAgreementNotSupported + } + // save tx in context to pass all the way down to KeyStore transactionContext := context.WithValue(ctx, storage.TransactionKey{}, tx) sqlDoc, err := manager.NewDocument(transactionContext, keyFlags) @@ -394,8 +394,8 @@ func (r *SqlManager) AddVerificationMethod(ctx context.Context, subject string, err := r.applyToDIDDocuments(ctx, subject, func(tx *gorm.DB, id did.DID, current *orm.DidDocument) (*orm.DidDocument, error) { // known limitation if keyUsage.Is(orm.KeyAgreementUsage) && id.Method == "web" { - return nil, errors.New("key agreement not supported for did:web") - // todo requires update to nutsCrypto module + return nil, ErrKeyAgreementNotSupported + // requires update to nutsCrypto module //verificationMethodKey, err = m.keyStore.NewRSA(ctx, func(key crypt.PublicKey) (string, error) { // return verificationMethodID.String(), nil //}) @@ -648,3 +648,132 @@ func sortDIDDocumentsByMethod(list []did.Document, methodOrder []string) { } copy(list, orderedList) } + +func (r *SqlManager) MigrateDIDHistoryToSQL(id did.DID, subject string, getHistory func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error)) error { + latestSQLVersion := -1 // -1 means it's a new DID + latestORMDocument, err := NewDIDDocumentManager(r.DB).Latest(id, nil) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // new DID, don't update latestSQLVersion + } else { + // don't migrate DID documents past their deactivation + latestDIDDocument, err := latestORMDocument.ToDIDDocument() + if err != nil { + return err + } + if resolver.IsDeactivated(latestDIDDocument) { + return nil + } + // set latestSQLVersion + latestSQLVersion = latestORMDocument.Version + } + + // get all new document updates + // NOTE: this assumes updates are only appended to the end. This breaks if the history of a document is altered. + history, err := getHistory(id, latestSQLVersion+1) + if err != nil { + return err + } + if len(history) == 0 { + return nil + } + + // convert history to orm objects + documentVersions := make([]orm.DidDocument, len(history)) + for i, entry := range history { + documentVersions[i], err = entry.ToORMDocument(subject) + if err != nil { + return err + } + + // break if this version deactivates the document + didDocument, err := documentVersions[i].ToDIDDocument() + if err != nil { + return err + } + if resolver.IsDeactivated(didDocument) { + documentVersions = documentVersions[:i+1] + break + } + } + + return r.DB.Transaction(func(tx *gorm.DB) error { + if latestSQLVersion == -1 { + // add subject to did table + DID := orm.DID{ + ID: id.String(), + Subject: subject, + } + err = tx.Create(&DID).Error + if err != nil { + return err + } + } + // add document history + for _, doc := range documentVersions { + err = tx.Create(&doc).Error + if err != nil { + return err + } + } + return nil + }) +} + +func (r *SqlManager) MigrateAddWebToNuts(ctx context.Context, id did.DID) error { + // get subject + // TODO: this should only run on migrations, so could use 'subject = id.String()' + var subject string + err := r.DB.Model(new(orm.DID)).Where("id = ?", id.String()).Select("subject").First(&subject).Error + if err != nil { + return err + } + + // check if subject has a did:web + subjectDIDs, err := r.ListDIDs(ctx, subject) + if err != nil { + return err + } + for _, subjectDID := range subjectDIDs { + if subjectDID.Method == "web" { + // already has a did:web + return nil + } + } + + // get latest did:nuts document + sqlDIDDocumentManager := NewDIDDocumentManager(r.DB) + nutsDoc, err := sqlDIDDocumentManager.Latest(id, nil) + if err != nil { + return err + } + + // don't add did:web if did:nuts is deactivated + nutsDidDoc, err := nutsDoc.ToDIDDocument() + if err != nil { + return err + } + if resolver.IsDeactivated(nutsDidDoc) { + return nil + } + + // create a did:web for this subject + webDoc, err := r.MethodManagers["web"].NewDocument(ctx, orm.AssertionKeyUsage()) + if err != nil { + return err + } + // add subject + webDID := orm.DID{ + ID: webDoc.DID.ID, + Subject: subject, + } + // store did:web; don't migrate services + _, err = sqlDIDDocumentManager.CreateOrUpdate(webDID, webDoc.VerificationMethods, nil) + if err != nil { + return err + } + + return nil +} diff --git a/vdr/didsubject/manager_test.go b/vdr/didsubject/manager_test.go index f2746a6c7a..7c9abb3c87 100644 --- a/vdr/didsubject/manager_test.go +++ b/vdr/didsubject/manager_test.go @@ -20,8 +20,11 @@ package didsubject import ( "context" + "encoding/json" + "errors" "fmt" ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/storage/orm" "net/url" "strings" @@ -141,7 +144,9 @@ func TestManager_Create(t *testing.T) { _, _, err := m.Create(audit.TestContext(), DefaultCreationOptions().With("")) - require.EqualError(t, err, "unknown option: string") + require.Error(t, err) + assert.ErrorIs(t, err, ErrSubjectValidation) + assert.ErrorContains(t, err, "unknown option: string") }) t.Run("already exists", func(t *testing.T) { db := testDB(t) @@ -163,7 +168,19 @@ func TestManager_Create(t *testing.T) { _, _, err := m.Create(audit.TestContext(), DefaultCreationOptions().With(SubjectCreationOption{Subject: ""})) - require.EqualError(t, err, "invalid subject (must follow pattern: ^[a-zA-Z0-9.-]+$)") + require.Error(t, err) + assert.ErrorIs(t, err, ErrSubjectValidation) + assert.ErrorContains(t, err, "invalid subject (must follow pattern: ^[a-zA-Z0-9._-]+$)") + }) + t.Run("contains allowed characters", func(t *testing.T) { + db := testDB(t) + m := SqlManager{DB: db, MethodManagers: map[string]MethodManager{ + "example": testMethod{}, + }} + + _, _, err := m.Create(audit.TestContext(), DefaultCreationOptions().With(SubjectCreationOption{Subject: "subject_with-special.characters"})) + + assert.NoError(t, err) }) t.Run("contains illegal character (space)", func(t *testing.T) { db := testDB(t) @@ -173,7 +190,9 @@ func TestManager_Create(t *testing.T) { _, _, err := m.Create(audit.TestContext(), DefaultCreationOptions().With(SubjectCreationOption{Subject: "subject with space"})) - require.EqualError(t, err, "invalid subject (must follow pattern: ^[a-zA-Z0-9.-]+$)") + require.Error(t, err) + assert.ErrorIs(t, err, ErrSubjectValidation) + assert.ErrorContains(t, err, "invalid subject (must follow pattern: ^[a-zA-Z0-9._-]+$)") }) }) } @@ -439,9 +458,13 @@ type testMethod struct { committed bool error error method string + document *orm.DidDocument } func (t testMethod) NewDocument(_ context.Context, _ orm.DIDKeyFlags) (*orm.DidDocument, error) { + if t.document != nil { + return t.document, nil + } method := t.method if method == "" { method = "example" @@ -476,3 +499,231 @@ func Test_sortDIDDocumentsByMethod(t *testing.T) { assert.Equal(t, "did:test:1", documents[0].ID.String()) assert.Equal(t, "did:example:1", documents[1].ID.String()) } + +func TestSqlManager_MigrateDIDHistoryToSQL(t *testing.T) { + testDID := did.MustParseDID("did:nuts:test") + vmID := did.MustParseDIDURL("did:nuts:test#key-1") + key, _ := spi.GenerateKeyPair() + vm, err := did.NewVerificationMethod(vmID, ssi.JsonWebKey2020, testDID, key.Public()) + require.NoError(t, err) + service := did.Service{ + ID: ssi.MustParseURI(testDID.String() + "#service-1"), + Type: "test", + ServiceEndpoint: "https://example.com", + } + created := time.Now().Add(-5 * time.Second) + subject := "test-subject" + + rawDocNew, err := json.Marshal(did.Document{ID: testDID, CapabilityInvocation: []did.VerificationRelationship{{VerificationMethod: vm}}, VerificationMethod: did.VerificationMethods{vm}}) + require.NoError(t, err) + rawDocUpdate, _ := json.Marshal(did.Document{ID: testDID, Service: []did.Service{service}, CapabilityInvocation: []did.VerificationRelationship{{VerificationMethod: vm}}}) + rawDocDeactivate, _ := json.Marshal(did.Document{ID: testDID}) + ormMigrateNew := orm.MigrationDocument{Raw: rawDocNew, Created: created, Updated: created, Version: 0} + ormMigrateUpdate := orm.MigrationDocument{Raw: rawDocUpdate, Created: created, Updated: created.Add(2 * time.Second), Version: 1} + ormMigrateDeactivate := orm.MigrationDocument{Raw: rawDocDeactivate, Created: created, Updated: created.Add(2 * time.Second), Version: 1} + ormDocNew, err := ormMigrateNew.ToORMDocument(subject) + require.NoError(t, err) + ormDocUpdate, _ := ormMigrateUpdate.ToORMDocument(subject) + ormDocDeactivate, _ := ormMigrateDeactivate.ToORMDocument(subject) + equal := func(t *testing.T, o1, o2 orm.DidDocument) { + //assert.NotEqual(t, o1.ID, o2.ID) // ToORMDocument generates a random ID everytime. Should probably be ignored. + assert.Equal(t, o1.DidID, o2.DidID) + assert.Equal(t, o1.DID, o2.DID) + assert.Equal(t, o1.CreatedAt, o2.CreatedAt) + assert.Equal(t, o1.UpdatedAt, o2.UpdatedAt) + assert.Equal(t, o1.Version, o2.Version) + assert.Equal(t, o1.VerificationMethods, o2.VerificationMethods) + assert.Equal(t, o1.Services, o2.Services) + assert.Equal(t, o1.Raw, o2.Raw) + } + t.Run("create", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + assert.Equal(t, testDID, id) + assert.Equal(t, 0, sinceVersion) + return []orm.MigrationDocument{ormMigrateNew, ormMigrateUpdate}, nil + }) + require.NoError(t, err) + + // version 0 + ormDoc, err := NewDIDDocumentManager(db).Latest(testDID, &created) + require.NoError(t, err) + equal(t, *ormDoc, ormDocNew) + // version 1 + ormDoc, err = NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocUpdate) + }) + t.Run("update", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + + // init version 0 + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + assert.Equal(t, 0, sinceVersion) + return []orm.MigrationDocument{ormMigrateNew}, nil + }) + require.NoError(t, err) + ormDoc, err := NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocNew) + + // update to version 1 + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + assert.Equal(t, 1, sinceVersion) + return []orm.MigrationDocument{ormMigrateUpdate}, nil + }) + require.NoError(t, err) + ormDoc, err = NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocUpdate) + }) + t.Run("deactivate", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + + // create in version 0, deactivate in version 1 + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + return []orm.MigrationDocument{ormMigrateNew, ormMigrateDeactivate}, nil + }) + require.NoError(t, err) + ormDoc, err := NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocDeactivate) + + // don't update deactivated document + assert.NotPanics(t, func() { + _ = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + panic("function should not be called") + }) + }) + }) + t.Run("error - getHistory", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + return nil, errors.New("test") + }) + assert.EqualError(t, err, "test") + }) +} + +func TestSqlManager_MigrateAddWebToNuts(t *testing.T) { + didNuts := did.MustParseDID("did:nuts:test") + didWeb := did.MustParseDID("did:web:example.com") + nutsDoc := generateTestORMDoc(t, didNuts, didNuts.String(), true) + webDoc := generateTestORMDoc(t, didWeb, didNuts.String(), false) // don't add service to check it gets migrated properly + + var err error + auditContext := audit.Context(context.Background(), "system", "VDR", "migrate_add_did:web_to_did:nuts") + + t.Run("ok", func(t *testing.T) { + db := testDB(t) + _, err = NewDIDDocumentManager(db).CreateOrUpdate(nutsDoc.DID, nutsDoc.VerificationMethods, nutsDoc.Services) + require.NoError(t, err) + m := SqlManager{DB: db, MethodManagers: map[string]MethodManager{ + "web": testMethod{document: &webDoc}, + }, PreferredOrder: []string{"web"}} + + // create did:web + err = m.MigrateAddWebToNuts(auditContext, didNuts) + assert.NoError(t, err) + + dids, err := NewDIDManager(db).FindBySubject(didNuts.String()) + assert.NoError(t, err) + require.Len(t, dids, 2) + assert.Equal(t, didNuts.String(), dids[0].ID) + assert.Equal(t, didWeb.String(), dids[1].ID) + + require.NoError(t, err) + docWeb, err := NewDIDDocumentManager(db).Latest(didWeb, nil) + require.NoError(t, err) + assert.Len(t, docWeb.Services, 0) + }) + t.Run("ok - already has did:web", func(t *testing.T) { + db := testDB(t) + _, err = NewDIDDocumentManager(db).CreateOrUpdate(nutsDoc.DID, nutsDoc.VerificationMethods, nutsDoc.Services) + require.NoError(t, err) + m := SqlManager{DB: db, MethodManagers: map[string]MethodManager{ + "web": testMethod{document: &webDoc}, + }, PreferredOrder: []string{"web"}} + + // migration 1; create did:web + err = m.MigrateAddWebToNuts(auditContext, didNuts) + assert.NoError(t, err) + + dids, err := NewDIDManager(db).FindBySubject(didNuts.String()) + assert.NoError(t, err) + require.Len(t, dids, 2) + + // migration 2; already has a did:web + err = m.MigrateAddWebToNuts(auditContext, didNuts) + assert.NoError(t, err) + + dids2, err := NewDIDManager(db).FindBySubject(didNuts.String()) + assert.NoError(t, err) + require.Len(t, dids2, 2) + assert.Equal(t, dids, dids2) + }) + t.Run("ok - deactivated", func(t *testing.T) { + db := testDB(t) + _, err = NewDIDDocumentManager(db).CreateOrUpdate(nutsDoc.DID, nil, nil) + require.NoError(t, err) + m := SqlManager{DB: db} + + // migrate is a noop + err = m.MigrateAddWebToNuts(auditContext, didNuts) + assert.NoError(t, err) + + dids, err := NewDIDManager(db).FindBySubject(didNuts.String()) + assert.NoError(t, err) + require.Len(t, dids, 1) + assert.Equal(t, didNuts.String(), dids[0].ID) + }) + t.Run("error - did not found", func(t *testing.T) { + db := testDB(t) + m := SqlManager{DB: db} + + // empty db + err = m.MigrateAddWebToNuts(auditContext, didNuts) + + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) + t.Run("error - doc not found", func(t *testing.T) { + db := testDB(t) + storage.AddDIDtoSQLDB(t, db, didNuts) // only add did, not the doc + m := SqlManager{DB: db} + + // migrate is a noop + err = m.MigrateAddWebToNuts(auditContext, didNuts) + + assert.ErrorIs(t, err, gorm.ErrRecordNotFound) + }) +} + +func generateTestORMDoc(t *testing.T, id did.DID, subject string, addService bool) orm.DidDocument { + // verification method + vmID := did.MustParseDIDURL(id.String() + "#key-1") + key, _ := spi.GenerateKeyPair() + vm, err := did.NewVerificationMethod(vmID, ssi.JsonWebKey2020, id, key.Public()) + require.NoError(t, err) + // service + var service did.Service + if addService { + service = did.Service{ + ID: ssi.MustParseURI(id.String() + "#service-1"), + Type: "test", + ServiceEndpoint: "https://example.com", + } + } + // generate and parse document + didDoc := did.Document{ID: id, VerificationMethod: did.VerificationMethods{vm}, CapabilityInvocation: []did.VerificationRelationship{{VerificationMethod: vm}}, Service: []did.Service{service}} + rawDoc, err := json.Marshal(didDoc) + require.NoError(t, err) + now := time.Now() + ormDoc, err := orm.MigrationDocument{Raw: rawDoc, Created: now, Updated: now, Version: 0}.ToORMDocument(subject) + require.NoError(t, err) + return ormDoc +} diff --git a/vdr/didsubject/mock.go b/vdr/didsubject/mock.go index d94d10b2a0..101e434dd6 100644 --- a/vdr/didsubject/mock.go +++ b/vdr/didsubject/mock.go @@ -23,6 +23,7 @@ import ( type MockMethodManager struct { ctrl *gomock.Controller recorder *MockMethodManagerMockRecorder + isgomock struct{} } // MockMethodManagerMockRecorder is the mock recorder for MockMethodManager. @@ -105,6 +106,7 @@ func (mr *MockMethodManagerMockRecorder) NewVerificationMethod(ctx, controller, type MockDocumentManager struct { ctrl *gomock.Controller recorder *MockDocumentManagerMockRecorder + isgomock struct{} } // MockDocumentManagerMockRecorder is the mock recorder for MockDocumentManager. @@ -156,6 +158,7 @@ func (mr *MockDocumentManagerMockRecorder) Update(ctx, id, next any) *gomock.Cal type MockManager struct { ctrl *gomock.Controller recorder *MockManagerMockRecorder + isgomock struct{} } // MockManagerMockRecorder is the mock recorder for MockManager. @@ -336,10 +339,63 @@ func (mr *MockManagerMockRecorder) UpdateService(ctx, subject, serviceID, servic return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockManager)(nil).UpdateService), ctx, subject, serviceID, service) } +// MockDocumentMigration is a mock of DocumentMigration interface. +type MockDocumentMigration struct { + ctrl *gomock.Controller + recorder *MockDocumentMigrationMockRecorder + isgomock struct{} +} + +// MockDocumentMigrationMockRecorder is the mock recorder for MockDocumentMigration. +type MockDocumentMigrationMockRecorder struct { + mock *MockDocumentMigration +} + +// NewMockDocumentMigration creates a new mock instance. +func NewMockDocumentMigration(ctrl *gomock.Controller) *MockDocumentMigration { + mock := &MockDocumentMigration{ctrl: ctrl} + mock.recorder = &MockDocumentMigrationMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDocumentMigration) EXPECT() *MockDocumentMigrationMockRecorder { + return m.recorder +} + +// MigrateAddWebToNuts mocks base method. +func (m *MockDocumentMigration) MigrateAddWebToNuts(ctx context.Context, id did.DID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MigrateAddWebToNuts", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// MigrateAddWebToNuts indicates an expected call of MigrateAddWebToNuts. +func (mr *MockDocumentMigrationMockRecorder) MigrateAddWebToNuts(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigrateAddWebToNuts", reflect.TypeOf((*MockDocumentMigration)(nil).MigrateAddWebToNuts), ctx, id) +} + +// MigrateDIDHistoryToSQL mocks base method. +func (m *MockDocumentMigration) MigrateDIDHistoryToSQL(id did.DID, subject string, getHistory func(did.DID, int) ([]orm.MigrationDocument, error)) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MigrateDIDHistoryToSQL", id, subject, getHistory) + ret0, _ := ret[0].(error) + return ret0 +} + +// MigrateDIDHistoryToSQL indicates an expected call of MigrateDIDHistoryToSQL. +func (mr *MockDocumentMigrationMockRecorder) MigrateDIDHistoryToSQL(id, subject, getHistory any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MigrateDIDHistoryToSQL", reflect.TypeOf((*MockDocumentMigration)(nil).MigrateDIDHistoryToSQL), id, subject, getHistory) +} + // MockCreationOptions is a mock of CreationOptions interface. type MockCreationOptions struct { ctrl *gomock.Controller recorder *MockCreationOptionsMockRecorder + isgomock struct{} } // MockCreationOptionsMockRecorder is the mock recorder for MockCreationOptions. @@ -391,6 +447,7 @@ func (mr *MockCreationOptionsMockRecorder) With(option any) *gomock.Call { type MockCreationOption struct { ctrl *gomock.Controller recorder *MockCreationOptionMockRecorder + isgomock struct{} } // MockCreationOptionMockRecorder is the mock recorder for MockCreationOption. @@ -414,6 +471,7 @@ func (m *MockCreationOption) EXPECT() *MockCreationOptionMockRecorder { type MockDocumentOwner struct { ctrl *gomock.Controller recorder *MockDocumentOwnerMockRecorder + isgomock struct{} } // MockDocumentOwnerMockRecorder is the mock recorder for MockDocumentOwner. diff --git a/vdr/didweb/web.go b/vdr/didweb/web.go index 50d9fe50fc..25f78ca10a 100644 --- a/vdr/didweb/web.go +++ b/vdr/didweb/web.go @@ -25,6 +25,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "io" "mime" "net/http" "time" @@ -37,16 +38,13 @@ var _ resolver.DIDResolver = (*Resolver)(nil) // Resolver is a DID resolver for the did:web method. type Resolver struct { - HttpClient *http.Client + HttpClient core.HTTPRequestDoer } // NewResolver creates a new did:web Resolver with default TLS configuration. func NewResolver() *Resolver { return &Resolver{ - HttpClient: &http.Client{ - Transport: client.DefaultCachingTransport, - Timeout: 5 * time.Second, - }, + HttpClient: client.NewWithCache(5 * time.Second), } } @@ -68,11 +66,14 @@ func (w Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen targetURL := baseURL.String() // TODO: Support DNS over HTTPS (DOH), https://www.rfc-editor.org/rfc/rfc8484 - httpResponse, err := w.HttpClient.Get(targetURL) + request, err := http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + return nil, nil, err + } + httpResponse, err := w.HttpClient.Do(request) if err != nil { return nil, nil, fmt.Errorf("did:web HTTP error: %w", err) } - defer httpResponse.Body.Close() if !(httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300) { return nil, nil, fmt.Errorf("did:web non-ok HTTP status: %s", httpResponse.Status) } @@ -98,7 +99,7 @@ func (w Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen } // Read document - data, err := core.LimitedReadAll(httpResponse.Body) + data, err := io.ReadAll(httpResponse.Body) if err != nil { return nil, nil, fmt.Errorf("did:web HTTP response read error: %w", err) } diff --git a/vdr/didweb/web_test.go b/vdr/didweb/web_test.go index 519464be39..b887de4096 100644 --- a/vdr/didweb/web_test.go +++ b/vdr/didweb/web_test.go @@ -20,7 +20,6 @@ package didweb import ( "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/nuts-node/http/client" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,10 +54,6 @@ const didDocTemplate = ` func TestResolver_NewResolver(t *testing.T) { resolver := NewResolver() assert.NotNil(t, resolver.HttpClient) - - t.Run("it uses cached transport", func(t *testing.T) { - assert.Same(t, client.DefaultCachingTransport, resolver.HttpClient.Transport) - }) } func TestResolver_Resolve(t *testing.T) { diff --git a/vdr/interface.go b/vdr/interface.go index 78d275e266..0c1996730b 100644 --- a/vdr/interface.go +++ b/vdr/interface.go @@ -37,8 +37,6 @@ type VDR interface { ResolveManaged(id did.DID) (*did.Document, error) // Resolver returns the resolver for getting the DID document for a DID. Resolver() resolver.DIDResolver - // SupportedMethods returns the activated DID methods. - SupportedMethods() []string // ConflictedDocuments returns the DID Document and metadata of all documents with a conflict. ConflictedDocuments() ([]did.Document, []resolver.DocumentMetadata, error) // PublicURL returns the public URL of the Nuts node, which is used as base URL for web-based DIDs. diff --git a/vdr/legacy_integration_test.go b/vdr/legacy_integration_test.go index 8e1d2a2ec1..3c7302aebf 100644 --- a/vdr/legacy_integration_test.go +++ b/vdr/legacy_integration_test.go @@ -256,6 +256,7 @@ func setup(t *testing.T) testContext { config.Strictmode = false config.Verbosity = "trace" config.Datadir = testDir + config.DIDMethods = []string{"nuts"} }) // Configure the logger: @@ -300,7 +301,6 @@ func setup(t *testing.T) testContext { ctrl := gomock.NewController(t) pkiMock := pki.NewMockValidator(ctrl) vdr := NewVDR(cryptoInstance, nutsNetwork, didStore, eventPublisher, storageEngine, pkiMock) - vdr.Config().(*Config).DIDMethods = []string{"nuts"} // Configure require.NoError(t, vdr.Configure(nutsConfig)) diff --git a/vdr/mock.go b/vdr/mock.go index ad01a6c9f0..ba54c789d3 100644 --- a/vdr/mock.go +++ b/vdr/mock.go @@ -23,6 +23,7 @@ import ( type MockVDR struct { ctrl *gomock.Controller recorder *MockVDRMockRecorder + isgomock struct{} } // MockVDRMockRecorder is the mock recorder for MockVDR. @@ -128,17 +129,3 @@ func (mr *MockVDRMockRecorder) Resolver() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolver", reflect.TypeOf((*MockVDR)(nil).Resolver)) } - -// SupportedMethods mocks base method. -func (m *MockVDR) SupportedMethods() []string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SupportedMethods") - ret0, _ := ret[0].([]string) - return ret0 -} - -// SupportedMethods indicates an expected call of SupportedMethods. -func (mr *MockVDRMockRecorder) SupportedMethods() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportedMethods", reflect.TypeOf((*MockVDR)(nil).SupportedMethods)) -} diff --git a/vdr/resolver/did_mock.go b/vdr/resolver/did_mock.go index 7123ce35b1..31476c5032 100644 --- a/vdr/resolver/did_mock.go +++ b/vdr/resolver/did_mock.go @@ -20,6 +20,7 @@ import ( type MockDIDResolver struct { ctrl *gomock.Controller recorder *MockDIDResolverMockRecorder + isgomock struct{} } // MockDIDResolverMockRecorder is the mock recorder for MockDIDResolver. diff --git a/vdr/resolver/finder_mock.go b/vdr/resolver/finder_mock.go index 7d26ffe6ec..705552eaa8 100644 --- a/vdr/resolver/finder_mock.go +++ b/vdr/resolver/finder_mock.go @@ -20,6 +20,7 @@ import ( type MockDocFinder struct { ctrl *gomock.Controller recorder *MockDocFinderMockRecorder + isgomock struct{} } // MockDocFinderMockRecorder is the mock recorder for MockDocFinder. @@ -62,6 +63,7 @@ func (mr *MockDocFinderMockRecorder) Find(arg0 ...any) *gomock.Call { type MockPredicate struct { ctrl *gomock.Controller recorder *MockPredicateMockRecorder + isgomock struct{} } // MockPredicateMockRecorder is the mock recorder for MockPredicate. diff --git a/vdr/resolver/key_mock.go b/vdr/resolver/key_mock.go index 4134af264f..6582c18b81 100644 --- a/vdr/resolver/key_mock.go +++ b/vdr/resolver/key_mock.go @@ -23,6 +23,7 @@ import ( type MockKeyResolver struct { ctrl *gomock.Controller recorder *MockKeyResolverMockRecorder + isgomock struct{} } // MockKeyResolverMockRecorder is the mock recorder for MockKeyResolver. @@ -77,6 +78,7 @@ func (mr *MockKeyResolverMockRecorder) ResolveKeyByID(keyID, metadata, relationT type MockNutsKeyResolver struct { ctrl *gomock.Controller recorder *MockNutsKeyResolverMockRecorder + isgomock struct{} } // MockNutsKeyResolverMockRecorder is the mock recorder for MockNutsKeyResolver. diff --git a/vdr/resolver/service_mock.go b/vdr/resolver/service_mock.go index 6fe0453fc6..e836738ec1 100644 --- a/vdr/resolver/service_mock.go +++ b/vdr/resolver/service_mock.go @@ -21,6 +21,7 @@ import ( type MockServiceResolver struct { ctrl *gomock.Controller recorder *MockServiceResolverMockRecorder + isgomock struct{} } // MockServiceResolverMockRecorder is the mock recorder for MockServiceResolver. diff --git a/vdr/vdr.go b/vdr/vdr.go index 1157ac3af3..859b02103d 100644 --- a/vdr/vdr.go +++ b/vdr/vdr.go @@ -63,12 +63,12 @@ var _ didsubject.Manager = (*Module)(nil) // It connects the Resolve, Create and Update DID methods to the network, and receives events back from the network which are processed in the store. // It is also a Runnable, Diagnosable and Configurable Nuts Engine. type Module struct { - config Config - publicURL *url.URL - store didnutsStore.Store - network network.Transactions - networkAmbassador didnuts.Ambassador - documentOwner didsubject.DocumentOwner + supportedDIDMethods []string + publicURL *url.URL + store didnutsStore.Store + network network.Transactions + networkAmbassador didnuts.Ambassador + documentOwner didsubject.DocumentOwner // nutsDocumentManager is used to manage did:nuts DID Documents // Deprecated: used by v1 api nutsDocumentManager didsubject.DocumentManager @@ -79,6 +79,8 @@ type Module struct { keyStore crypto.KeyStore storageInstance storage.Engine eventManager events.Event + // migrations are registered functions to simplify testing + migrations []migration pkiValidator pki.Validator // new style DID management @@ -110,10 +112,6 @@ func (r *Module) Resolver() resolver.DIDResolver { return r.didResolver } -func (r *Module) SupportedMethods() []string { - return r.config.DIDMethods -} - // NewVDR creates a new Module with provided params func NewVDR(cryptoClient crypto.KeyStore, networkClient network.Transactions, didStore didnutsStore.Store, eventManager events.Event, storageInstance storage.Engine, pkiValidator pki.Validator) *Module { @@ -128,6 +126,7 @@ func NewVDR(cryptoClient crypto.KeyStore, networkClient network.Transactions, } m.ctx, m.cancel = context.WithCancel(context.Background()) m.routines = new(sync.WaitGroup) + m.migrations = m.allMigrations() return m } @@ -135,22 +134,19 @@ func (r *Module) Name() string { return ModuleName } -func (r *Module) Config() interface{} { - return &r.config -} - // Configure configures the Module engine. func (r *Module) Configure(config core.ServerConfig) error { + r.supportedDIDMethods = config.DIDMethods var err error if r.publicURL, err = config.ServerURL(); err != nil { return err } // at least one method should be configured - if len(r.config.DIDMethods) == 0 { + if len(r.supportedDIDMethods) == 0 { return errors.New("at least one DID method should be configured") } // check if all configured methods are supported - for _, method := range r.config.DIDMethods { + for _, method := range r.supportedDIDMethods { switch method { case didnuts.MethodName, didweb.MethodName: continue @@ -159,9 +155,11 @@ func (r *Module) Configure(config core.ServerConfig) error { } } - r.networkAmbassador = didnuts.NewAmbassador(r.network, r.store, r.eventManager) + // only create ambassador when did:nuts is enabled + if slices.Contains(r.supportedDIDMethods, "nuts") { + r.networkAmbassador = didnuts.NewAmbassador(r.network, r.store, r.eventManager) + } db := r.storageInstance.GetSQLDatabase() - methodManagers := make(map[string]didsubject.MethodManager) r.didResolver.(*resolver.DIDResolverRouter).Register(didjwk.MethodName, didjwk.NewResolver()) r.didResolver.(*resolver.DIDResolverRouter).Register(didkey.MethodName, didkey.NewResolver()) @@ -169,18 +167,20 @@ func (r *Module) Configure(config core.ServerConfig) error { // Register DID resolver and DID methods we can resolve r.ownedDIDResolver = didsubject.Resolver{DB: db} - // Methods we can produce from the Nuts node - // did:nuts - nutsManager := didnuts.NewManager(r.keyStore, r.network, r.store, r.didResolver, db) - r.nutsDocumentManager = nutsManager - methodManagers = map[string]didsubject.MethodManager{} r.documentOwner = &MultiDocumentOwner{ DocumentOwners: []didsubject.DocumentOwner{ newCachingDocumentOwner(DBDocumentOwner{DB: db}, r.didResolver), newCachingDocumentOwner(privateKeyDocumentOwner{keyResolver: r.keyStore}, r.didResolver), }, } - if slices.Contains(r.config.DIDMethods, didnuts.MethodName) { + + // Methods we can produce from the Nuts node + methodManagers := map[string]didsubject.MethodManager{} + + // did:nuts + nutsManager := didnuts.NewManager(r.keyStore, r.network, r.store, r.didResolver, db) + r.nutsDocumentManager = nutsManager + if slices.Contains(r.supportedDIDMethods, didnuts.MethodName) { methodManagers[didnuts.MethodName] = nutsManager r.didResolver.(*resolver.DIDResolverRouter).Register(didnuts.MethodName, &didnuts.Resolver{Store: r.store}) } @@ -202,32 +202,29 @@ func (r *Module) Configure(config core.ServerConfig) error { didweb.NewResolver(), }, } - if slices.Contains(r.config.DIDMethods, didweb.MethodName) { + if slices.Contains(r.supportedDIDMethods, didweb.MethodName) { methodManagers[didweb.MethodName] = webManager r.didResolver.(*resolver.DIDResolverRouter).Register(didweb.MethodName, webResolver) } - r.Manager = didsubject.New(db, methodManagers, r.keyStore, r.config.DIDMethods) + r.Manager = didsubject.New(db, methodManagers, r.keyStore, r.supportedDIDMethods) // Initiate the routines for auto-updating the data. - return r.networkAmbassador.Configure() + if r.networkAmbassador != nil { + return r.networkAmbassador.Configure() + } + return nil } func (r *Module) Start() error { - err := r.networkAmbassador.Start() - if err != nil { - return err + // nothing to start if did:nuts is disabled + if r.networkAmbassador == nil { + return nil } - - // VDR migration needs to be started after ambassador has started! - count, err := r.store.DocumentCount() + err := r.networkAmbassador.Start() if err != nil { return err } - if count == 0 { - // remove after v6 release - _, err = r.network.Reprocess(context.Background(), "application/did+json") - } // start DID Document rollback loop r.routines.Add(1) @@ -366,40 +363,105 @@ func (r *Module) Migrate() error { if err != nil { return err } - auditContext := audit.Context(context.Background(), "system", ModuleName, "migrate") + + // only migrate if did:nuts is activated on the node + if slices.Contains(r.supportedDIDMethods, "nuts") { + for _, m := range r.migrations { + log.Logger().Infof("Running did:nuts migration: '%s'", m.name) + m.migrate(owned) + } + } + return nil +} + +// migration is the signature each migration function in Module.migrations uses +// there is no error return, if something is fatal the function should panic +type migrationFn func(owned []did.DID) + +type migration struct { + migrate migrationFn + name string +} + +func (r *Module) allMigrations() []migration { + return []migration{ // key will be printed as description of the migration + {r.migrateRemoveControllerFromDIDNuts, "remove controller"}, // must come before migrateHistoryOwnedDIDNuts so controller removal is also migrated. + {r.migrateHistoryOwnedDIDNuts, "document history"}, + {r.migrateAddDIDWebToOwnedDIDNuts, "add did:web to subject"}, // must come after migrateHistoryOwnedDIDNuts since it acts on the SQL store. + } +} + +// migrateRemoveControllerFromDIDNuts removes the controller from all did:nuts identifiers under own control. +// This ignores any DIDs that are not did:nuts. +func (r *Module) migrateRemoveControllerFromDIDNuts(owned []did.DID) { + auditContext := audit.Context(context.Background(), "system", ModuleName, "migrate_remove_did_nuts_controller") // resolve the DID Document if the did starts with did:nuts for _, did := range owned { - if did.Method == didnuts.MethodName { - doc, _, err := r.Resolve(did, nil) - if err != nil { - if !(errors.Is(err, resolver.ErrDeactivated) || errors.Is(err, resolver.ErrNoActiveController)) { - log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") - } - continue + if did.Method != didnuts.MethodName { // skip non did:nuts + continue + } + doc, _, err := r.Resolve(did, nil) + if err != nil { + if !(errors.Is(err, resolver.ErrDeactivated) || errors.Is(err, resolver.ErrNoActiveController)) { + log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") } - if len(doc.Controller) > 0 { - doc.Controller = nil - - if len(doc.VerificationMethod) == 0 { - log.Logger().WithField(core.LogFieldDID, doc.ID.String()).Warnf("No verification method found in owned DID document") - continue - } - - if len(doc.CapabilityInvocation) == 0 { - // add all keys as capabilityInvocation keys - for _, vm := range doc.VerificationMethod { - doc.CapabilityInvocation.Add(vm) - } - } - - err = r.nutsDocumentManager.Update(auditContext, did, *doc) - if err != nil { - if !(errors.Is(err, resolver.ErrKeyNotFound)) { - log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") - } - } + continue + } + if len(doc.Controller) == 0 { // has no controller + continue + } + + // try to remove controller + doc.Controller = nil + + if len(doc.VerificationMethod) == 0 { + log.Logger().WithField(core.LogFieldDID, doc.ID.String()).Warnf("No verification method found in owned DID document") + continue + } + + if len(doc.CapabilityInvocation) == 0 { + // add all keys as capabilityInvocation keys + for _, vm := range doc.VerificationMethod { + doc.CapabilityInvocation.Add(vm) + } + } + + err = r.nutsDocumentManager.Update(auditContext, did, *doc) + if err != nil { + if !(errors.Is(err, resolver.ErrKeyNotFound)) { + log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") } } } - return nil +} + +// migrateHistoryOwnedDIDNuts migrates did:nuts DIDs from the VDR key-value storage to SQL storage +// This ignores any DIDs that are not did:nuts. +func (r *Module) migrateHistoryOwnedDIDNuts(owned []did.DID) { + for _, id := range owned { + if id.Method != didnuts.MethodName { // skip non did:nuts + continue + } + err := r.Manager.(didsubject.DocumentMigration).MigrateDIDHistoryToSQL(id, id.String(), r.store.HistorySinceVersion) + if err != nil { + log.Logger().WithError(err).Errorf("Failed to migrate DID document history to SQL for %s", id) + } + } +} + +func (r *Module) migrateAddDIDWebToOwnedDIDNuts(owned []did.DID) { + if !slices.Contains(r.supportedDIDMethods, "web") { + log.Logger().Info("did:web not in supported did methods. Abort migration.") + return + } + auditContext := audit.Context(context.Background(), "system", ModuleName, "migrate_add_did:web_to_did:nuts") + for _, id := range owned { + if id.Method != didnuts.MethodName { // skip non did:nuts + continue + } + err := r.Manager.(didsubject.DocumentMigration).MigrateAddWebToNuts(auditContext, id) + if err != nil { + log.Logger().WithError(err).Errorf("Failed to add a did:web DID for %s", id) + } + } } diff --git a/vdr/vdr_test.go b/vdr/vdr_test.go index 29638876eb..ce6c593695 100644 --- a/vdr/vdr_test.go +++ b/vdr/vdr_test.go @@ -25,7 +25,6 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" - "errors" "github.com/lestrrat-go/jwx/v2/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -55,7 +54,7 @@ import ( // testCtx contains the controller and mocks needed fot testing the Manipulator type vdrTestCtx struct { ctrl *gomock.Controller - vdr Module + vdr *Module mockStore *didstore.MockStore mockNetwork *network.MockTransactions keyStore nutsCrypto.KeyStore @@ -88,10 +87,11 @@ func newVDRTestCtx(t *testing.T) vdrTestCtx { DB: db, MethodManagers: make(map[string]didsubject.MethodManager), } + vdr.supportedDIDMethods = []string{"web", "nuts"} resolverRouter.Register(didnuts.MethodName, &didnuts.Resolver{Store: mockStore}) return vdrTestCtx{ ctrl: ctrl, - vdr: *vdr, + vdr: vdr, mockAmbassador: mockAmbassador, mockStore: mockStore, mockNetwork: mockNetwork, @@ -108,41 +108,6 @@ func TestNewVDR(t *testing.T) { assert.IsType(t, &Module{}, vdr) } -func TestVDR_Start(t *testing.T) { - t.Run("migration", func(t *testing.T) { - t.Run("migrate on 0 document count", func(t *testing.T) { - ctx := newVDRTestCtx(t) - ctx.mockAmbassador.EXPECT().Start() - ctx.mockStore.EXPECT().DocumentCount().Return(uint(0), nil) - ctx.mockNetwork.EXPECT().Reprocess(context.Background(), "application/did+json").Return(nil, nil) - - err := ctx.vdr.Start() - - require.NoError(t, err) - }) - t.Run("don't migrate on > 0 document count", func(t *testing.T) { - ctx := newVDRTestCtx(t) - ctx.mockAmbassador.EXPECT().Start() - ctx.mockStore.EXPECT().DocumentCount().Return(uint(1), nil) - - err := ctx.vdr.Start() - - require.NoError(t, err) - }) - t.Run("error on migration error", func(t *testing.T) { - ctx := newVDRTestCtx(t) - ctx.mockAmbassador.EXPECT().Start() - testError := errors.New("test") - ctx.mockStore.EXPECT().DocumentCount().Return(uint(0), testError) - - err := ctx.vdr.Start() - - assert.Equal(t, testError, err) - }) - }) - -} - func TestVDR_ConflictingDocuments(t *testing.T) { t.Run("diagnostics", func(t *testing.T) { @@ -179,7 +144,6 @@ func TestVDR_ConflictingDocuments(t *testing.T) { ctrl := gomock.NewController(t) pkiMock := pki.NewMockValidator(ctrl) vdr := NewVDR(client, nil, didstore.NewTestStore(t), nil, storageEngine, pkiMock) - vdr.Config().(*Config).DIDMethods = []string{"web", "nuts"} _ = vdr.Configure(core.TestServerConfig()) didDocument := did.Document{ID: TestDIDA} @@ -216,7 +180,6 @@ func TestVDR_ConflictingDocuments(t *testing.T) { // change vdr to allow for Configure() vdr := NewVDR(test.keyStore, nil, didstore.NewTestStore(t), nil, test.storageEngine, pkiMock) tmpResolver := vdr.didResolver - vdr.Config().(*Config).DIDMethods = []string{"web", "nuts"} _ = vdr.Configure(core.TestServerConfig()) vdr.didResolver = tmpResolver @@ -269,9 +232,9 @@ func TestVDR_Configure(t *testing.T) { Body: io.NopCloser(strings.NewReader(`{"id": "did:web:example.com"}`)), }, nil }) + instance := NewVDR(nil, nil, nil, nil, storageInstance, pkiMock) - instance.Config().(*Config).DIDMethods = []string{"web", "nuts"} - err := instance.Configure(core.ServerConfig{URL: "https://nuts.nl"}) + err := instance.Configure(core.ServerConfig{URL: "https://nuts.nl", DIDMethods: []string{"web", "nuts"}}) require.NoError(t, err) doc, md, err := instance.Resolver().Resolve(did.MustParseDID("did:web:example.com"), nil) @@ -282,11 +245,8 @@ func TestVDR_Configure(t *testing.T) { }) t.Run("resolves local DID from database", func(t *testing.T) { db := storageInstance.GetSQLDatabase() - ctrl := gomock.NewController(t) - pkiMock := pki.NewMockValidator(ctrl) instance := NewVDR(nutsCrypto.NewDatabaseCryptoInstance(db), nil, nil, nil, storageInstance, pkiMock) - instance.Config().(*Config).DIDMethods = []string{"web", "nuts"} - err := instance.Configure(core.ServerConfig{URL: "https://example.com"}) + err := instance.Configure(core.ServerConfig{URL: "https://example.com", DIDMethods: []string{"web", "nuts"}}) require.NoError(t, err) sqlDIDDocumentManager := didsubject.NewDIDDocumentManager(db) sqlDID := orm.DID{ @@ -314,7 +274,6 @@ func TestVDR_Configure(t *testing.T) { require.NoError(t, err) instance := NewVDR(nil, nil, nil, nil, storageInstance, pkiMock) - instance.Config().(*Config).DIDMethods = []string{"web", "nuts"} err = instance.Configure(core.TestServerConfig()) require.NoError(t, err) @@ -329,7 +288,6 @@ func TestVDR_Configure(t *testing.T) { }) t.Run("it can resolve using did:key", func(t *testing.T) { instance := NewVDR(nil, nil, nil, nil, storageInstance, pkiMock) - instance.Config().(*Config).DIDMethods = []string{"web", "nuts"} err := instance.Configure(core.TestServerConfig()) require.NoError(t, err) @@ -356,103 +314,161 @@ func TestVDR_Migrate(t *testing.T) { require.NoError(t, err) assert.Contains(t, msg, expected) } - - t.Run("ignores self-controlled documents", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) + t.Run("ignores non did:nuts", func(t *testing.T) { ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{ID: TestDIDA}, nil, nil) - + testDIDWeb := did.MustParseDID("did:web:example.com") + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{testDIDWeb}, nil) err := ctx.vdr.Migrate() - - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) + assert.NoError(t, err) + assert.Len(t, ctx.vdr.migrations, 3) // confirm its running allMigrations() that currently is only did:nuts }) - t.Run("makes documents self-controlled", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - keyStore := nutsCrypto.NewMemoryCryptoInstance(t) - keyRef, publicKey, err := keyStore.New(ctx.ctx, didnuts.DIDKIDNamingFunc) - require.NoError(t, err) - methodID := did.MustParseDIDURL(keyRef.KID) - methodID.ID = TestDIDA.ID - vm, _ := did.NewVerificationMethod(methodID, ssi.JsonWebKey2020, TestDIDA, publicKey) - documentA := did.Document{Context: []interface{}{did.DIDContextV1URI()}, ID: TestDIDA, Controller: []did.DID{TestDIDB}} - documentA.AddAssertionMethod(vm) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(nil) - - err = ctx.vdr.Migrate() + t.Run("controller migration", func(t *testing.T) { + controllerMigrationSetup := func(t *testing.T) vdrTestCtx { + t.Cleanup(func() { hook.Reset() }) + ctx := newVDRTestCtx(t) + ctx.vdr.migrations = []migration{{ctx.vdr.migrateRemoveControllerFromDIDNuts, "remove controller"}} + return ctx + } + t.Run("ignores self-controlled documents", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{ID: TestDIDA}, nil, nil) - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) - }) - t.Run("deactivated is ignored", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) + err := ctx.vdr.Migrate() - err := ctx.vdr.Migrate() + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("makes documents self-controlled", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + keyStore := nutsCrypto.NewMemoryCryptoInstance(t) + keyRef, publicKey, err := keyStore.New(ctx.ctx, didnuts.DIDKIDNamingFunc) + require.NoError(t, err) + methodID := did.MustParseDIDURL(keyRef.KID) + methodID.ID = TestDIDA.ID + vm, _ := did.NewVerificationMethod(methodID, ssi.JsonWebKey2020, TestDIDA, publicKey) + documentA := did.Document{Context: []interface{}{did.DIDContextV1URI()}, ID: TestDIDA, Controller: []did.DID{TestDIDB}} + documentA.AddAssertionMethod(vm) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(nil) + + err = ctx.vdr.Migrate() - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) - }) - t.Run("no active controller is ignored", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, nil, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&did.Document{ID: TestDIDB}, nil, nil) + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("deactivated is ignored", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) - err := ctx.vdr.Migrate() + err := ctx.vdr.Migrate() - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) - }) - t.Run("error is logged", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, assert.AnError) + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("no active controller is ignored", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, nil, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&did.Document{ID: TestDIDB}, nil, nil) - err := ctx.vdr.Migrate() + err := ctx.vdr.Migrate() - require.NoError(t, err) - assertLog(t, "Could not update owned DID document, continuing with next document") - assertLog(t, "assert.AnError general error for testing") + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("error is logged", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, assert.AnError) + + err := ctx.vdr.Migrate() + + require.NoError(t, err) + assertLog(t, "Could not update owned DID document, continuing with next document") + assertLog(t, "assert.AnError general error for testing") + }) + t.Run("no verification method is logged", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{Controller: []did.DID{TestDIDB}}, nil, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil) + + err := ctx.vdr.Migrate() + + require.NoError(t, err) + assertLog(t, "No verification method found in owned DID document") + }) + t.Run("update error is logged", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(assert.AnError) + + err := ctx.vdr.Migrate() + + require.NoError(t, err) + assertLog(t, "Could not update owned DID document, continuing with next document") + assertLog(t, "assert.AnError general error for testing") + }) }) - t.Run("no verification method is logged", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{Controller: []did.DID{TestDIDB}}, nil, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil) - err := ctx.vdr.Migrate() + t.Run("history migration", func(t *testing.T) { + historyMigrationSetup := func(t *testing.T) vdrTestCtx { + t.Cleanup(func() { hook.Reset() }) + ctx := newVDRTestCtx(t) + ctx.vdr.migrations = []migration{{ctx.vdr.migrateHistoryOwnedDIDNuts, "history migration"}} + return ctx + } + t.Run("logs error", func(t *testing.T) { + ctx := historyMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().HistorySinceVersion(TestDIDA, 0).Return(nil, assert.AnError).AnyTimes() - require.NoError(t, err) - assertLog(t, "No verification method found in owned DID document") + err := ctx.vdr.Migrate() + + assert.NoError(t, err) + assertLog(t, "assert.AnError general error for testing") + }) }) - t.Run("update error is logged", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(assert.AnError) - err := ctx.vdr.Migrate() + t.Run("add did:web to subject", func(t *testing.T) { + didwebMigrationSetup := func(t *testing.T) vdrTestCtx { + t.Cleanup(func() { hook.Reset() }) + ctx := newVDRTestCtx(t) + ctx.vdr.migrations = []migration{{ctx.vdr.migrateAddDIDWebToOwnedDIDNuts, "add did:web to subject"}} + return ctx + } + t.Run("web not in supported methods", func(t *testing.T) { + logrus.StandardLogger().Level = logrus.InfoLevel + defer func() { logrus.StandardLogger().Level = logrus.WarnLevel }() + ctx := didwebMigrationSetup(t) + ctx.vdr.migrations = []migration{{ctx.vdr.migrateAddDIDWebToOwnedDIDNuts, "add did:web to subject"}} + ctx.vdr.supportedDIDMethods = []string{"nuts"} + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + + err := ctx.vdr.Migrate() - require.NoError(t, err) - assertLog(t, "Could not update owned DID document, continuing with next document") - assertLog(t, "assert.AnError general error for testing") + require.NoError(t, err) + assertLog(t, "did:web not in supported did methods. Abort migration.") + }) + t.Run("logs error", func(t *testing.T) { + ctx := didwebMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + + err := ctx.vdr.Migrate() + + assert.NoError(t, err) + assertLog(t, "Failed to add a did:web DID for did:nuts:") // test sql store does not contain TestDIDA + }) }) }