diff --git a/README.rst b/README.rst index f11f43e231..b8ab5f4975 100644 --- a/README.rst +++ b/README.rst @@ -227,7 +227,7 @@ The following options can be configured on the server: http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. **JSONLD** - jsonld.contexts.localmapping [https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.localmapping [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. **Network** network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6246720576..e0d002a280 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -283,8 +283,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // OAuthAuthorizationServerMetadata returns the Authorization Server's metadata func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) { - // TODO: must be web DID once web DID creation and DB are implemented - ownDID := idToNutsDID(request.Id) + ownDID := r.idToDID(request.Id) owned, err := r.vdr.IsOwner(ctx, ownDID) if err != nil { if resolver.IsFunctionalResolveError(err) { @@ -302,17 +301,15 @@ func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request O return OAuthAuthorizationServerMetadata200JSONResponse(authorizationServerMetadata(*identity)), nil } -func (r Wrapper) GetWebDID(ctx context.Context, request GetWebDIDRequestObject) (GetWebDIDResponseObject, error) { - baseURL := *(r.auth.PublicURL().JoinPath(apiPath)) - // TODO: must be web DID once web DID creation and DB are implemented - ownDID := idToNutsDID(request.Id) +func (r Wrapper) GetWebDID(_ context.Context, request GetWebDIDRequestObject) (GetWebDIDResponseObject, error) { + ownDID := r.idToDID(request.Id) - document, err := r.vdr.DeriveWebDIDDocument(ctx, baseURL, ownDID) + document, err := r.vdr.ResolveManaged(ownDID) if err != nil { if resolver.IsFunctionalResolveError(err) { return GetWebDID404Response{}, nil } - log.Logger().WithError(err).Errorf("Could not resolve Nuts DID: %s", ownDID.String()) + log.Logger().WithError(err).Errorf("Could not resolve Web DID: %s", ownDID.String()) return nil, errors.New("unable to resolve DID") } return GetWebDID200JSONResponse(*document), nil diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 7dd1cab95d..1c322b56fe 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -28,7 +28,6 @@ import ( "testing" "github.com/labstack/echo/v4" - 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" @@ -49,14 +48,15 @@ import ( var nutsDID = did.MustParseDID("did:nuts:123") var webDID = did.MustParseDID("did:web:example.com:iam:123") +var webIDPart = "123" func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("ok", func(t *testing.T) { // 200 ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(true, nil) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(true, nil) - res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID}) + res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) require.NoError(t, err) assert.IsType(t, OAuthAuthorizationServerMetadata200JSONResponse{}, res) @@ -65,9 +65,9 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("error - did not managed by this node", func(t *testing.T) { //404 ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID) + ctx.vdr.EXPECT().IsOwner(nil, webDID) - res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID}) + res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) assert.Equal(t, 404, statusCodeFrom(err)) assert.EqualError(t, err, "authz server metadata: did not owned") @@ -76,9 +76,9 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("error - did does not exist", func(t *testing.T) { //404 ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, resolver.ErrNotFound) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, resolver.ErrNotFound) - res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID}) + res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) assert.Equal(t, 404, statusCodeFrom(err)) assert.EqualError(t, err, "authz server metadata: unable to find the DID document") @@ -87,9 +87,9 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("error - internal error 500", func(t *testing.T) { //500 ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, errors.New("unknown error")) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, errors.New("unknown error")) - res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: nutsDID.ID}) + res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) assert.Equal(t, 500, statusCodeFrom(err)) assert.EqualError(t, err, "authz server metadata: unknown error") @@ -99,8 +99,7 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { func TestWrapper_GetWebDID(t *testing.T) { webDID := did.MustParseDID("did:web:example.com:iam:123") - publicURL := ssi.MustParseURI("https://example.com").URL - webDIDBaseURL := publicURL.JoinPath("/iam") + id := "123" ctx := audit.TestContext() expectedWebDIDDoc := did.Document{ ID: webDID, @@ -111,27 +110,27 @@ func TestWrapper_GetWebDID(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newTestClient(t) - test.vdr.EXPECT().DeriveWebDIDDocument(gomock.Any(), *webDIDBaseURL, nutsDID).Return(&expectedWebDIDDoc, nil) + test.vdr.EXPECT().ResolveManaged(webDID).Return(&expectedWebDIDDoc, nil) - response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{nutsDID.ID}) + response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id}) assert.NoError(t, err) assert.Equal(t, expectedWebDIDDoc, did.Document(response.(GetWebDID200JSONResponse))) }) t.Run("unknown DID", func(t *testing.T) { test := newTestClient(t) - test.vdr.EXPECT().DeriveWebDIDDocument(ctx, *webDIDBaseURL, nutsDID).Return(nil, resolver.ErrNotFound) + test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, resolver.ErrNotFound) - response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{nutsDID.ID}) + response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id}) assert.NoError(t, err) assert.IsType(t, GetWebDID404Response{}, response) }) t.Run("other error", func(t *testing.T) { test := newTestClient(t) - test.vdr.EXPECT().DeriveWebDIDDocument(gomock.Any(), *webDIDBaseURL, nutsDID).Return(nil, errors.New("failed")) + test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, errors.New("failed")) - response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{nutsDID.ID}) + response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id}) assert.EqualError(t, err, "unable to resolve DID") assert.Nil(t, response) diff --git a/cmd/root.go b/cmd/root.go index a531de27d0..c9d1d9eda2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,13 +23,9 @@ import ( "context" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/discovery" - "github.com/nuts-foundation/nuts-node/vdr/resolver" - - "github.com/nuts-foundation/nuts-node/golden_hammer" - goldenHammerCmd "github.com/nuts-foundation/nuts-node/golden_hammer/cmd" - "github.com/nuts-foundation/nuts-node/vdr/didnuts" - "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/pflag" "io" "os" "runtime/pprof" @@ -47,9 +43,12 @@ import ( "github.com/nuts-foundation/nuts-node/didman" didmanAPI "github.com/nuts-foundation/nuts-node/didman/api/v1" didmanCmd "github.com/nuts-foundation/nuts-node/didman/cmd" + "github.com/nuts-foundation/nuts-node/discovery" discoveryCmd "github.com/nuts-foundation/nuts-node/discovery/cmd" "github.com/nuts-foundation/nuts-node/events" eventsCmd "github.com/nuts-foundation/nuts-node/events/cmd" + "github.com/nuts-foundation/nuts-node/golden_hammer" + goldenHammerCmd "github.com/nuts-foundation/nuts-node/golden_hammer/cmd" httpEngine "github.com/nuts-foundation/nuts-node/http" httpCmd "github.com/nuts-foundation/nuts-node/http/cmd" "github.com/nuts-foundation/nuts-node/jsonld" @@ -65,10 +64,11 @@ import ( vcrCmd "github.com/nuts-foundation/nuts-node/vcr/cmd" "github.com/nuts-foundation/nuts-node/vdr" vdrAPI "github.com/nuts-foundation/nuts-node/vdr/api/v1" + vdrAPIv2 "github.com/nuts-foundation/nuts-node/vdr/api/v2" vdrCmd "github.com/nuts-foundation/nuts-node/vdr/cmd" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/pflag" + "github.com/nuts-foundation/nuts-node/vdr/didnuts" + "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) var stdOutWriter io.Writer = os.Stdout @@ -191,7 +191,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { didStore := didstore.New(storageInstance.GetProvider(vdr.ModuleName)) eventManager := events.NewManager() networkInstance := network.NewNetworkInstance(network.DefaultConfig(), didStore, cryptoInstance, eventManager, storageInstance.GetProvider(network.ModuleName), pkiInstance) - vdrInstance := vdr.NewVDR(cryptoInstance, networkInstance, didStore, eventManager) + vdrInstance := vdr.NewVDR(cryptoInstance, networkInstance, didStore, eventManager, storageInstance) credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) discoveryInstance := discovery.New(storageInstance, credentialInstance) @@ -209,6 +209,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { Updater: vdrInstance, Resolver: vdrInstance.Resolver(), }}) + system.RegisterRoutes(&vdrAPIv2.Wrapper{VDR: vdrInstance}) system.RegisterRoutes(&vcrAPI.Wrapper{VCR: credentialInstance, ContextManager: jsonld}) system.RegisterRoutes(&openid4vciAPI.Wrapper{ VCR: credentialInstance, diff --git a/core/test.go b/core/test.go index 89d3ab929e..663856bf0b 100644 --- a/core/test.go +++ b/core/test.go @@ -40,6 +40,7 @@ func TestServerConfig(template ServerConfig) ServerConfig { config.Datadir = template.Datadir config.Strictmode = template.Strictmode config.InternalRateLimiter = template.InternalRateLimiter + config.URL = template.URL return *config } diff --git a/crypto/test.go b/crypto/test.go index 1e90795677..9184ee2636 100644 --- a/crypto/test.go +++ b/crypto/test.go @@ -125,3 +125,25 @@ func (t TestKey) Public() crypto.PublicKey { func (t TestKey) Private() crypto.PrivateKey { return t.PrivateKey } + +// TestPublicKey is a Key impl for testing purposes that only contains a public key. It can't be used for signing. +type TestPublicKey struct { + Kid string + PublicKey crypto.PublicKey +} + +func (t TestPublicKey) Signer() crypto.Signer { + panic("test public key is not for signing") +} + +func (t TestPublicKey) KID() string { + return t.Kid +} + +func (t TestPublicKey) Public() crypto.PublicKey { + return t.PublicKey +} + +func (t TestPublicKey) Private() crypto.PrivateKey { + panic("test public key is not for signing") +} diff --git a/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index ac8c33f2fb..b32c1400b4 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -412,7 +412,7 @@ Print conflicted documents and their metadata nuts vdr create-did ^^^^^^^^^^^^^^^^^^^ -Registers a new DID +When using the V2 API, a web:did will be created. All the other options are ignored for a web:did. :: @@ -430,6 +430,7 @@ Registers a new DID --timeout duration Client time-out when performing remote operations, such as '500ms' or '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. (default 10s) --token string Token to be used for authenticating on the remote node. Takes precedence over 'token-file'. --token-file string File from which the authentication token will be read. If not specified it will try to read the token from the '.nuts-client.cfg' file in the user's home dir. + --v2 Pass 'true' to use the V2 API and create a web:did. --verbosity string Log level (trace, debug, info, warn, error) (default "info") nuts vdr deactivate diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 85076e29c5..b0b4b41ede 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -53,7 +53,7 @@ http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. **JSONLD** - jsonld.contexts.localmapping [https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.localmapping [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. **Network** network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml index 4f62fcffad..57dae33a7d 100644 --- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml @@ -24,7 +24,7 @@ services: - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro" - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" - "./node-A/html:/etc/nginx/html:ro" - nodeB: + nodeB-backend: image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}" ports: - "21323:1323" @@ -38,3 +38,13 @@ services: - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: nginx:1.25.1 + ports: + - "20443:443" + volumes: + - "./node-B/nginx.conf:/etc/nginx/nginx.conf:ro" + - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/server.pem:ro" + - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro" + - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" + - "./node-B/html:/etc/nginx/html:ro" diff --git a/e2e-tests/oauth-flow/rfc021/node-B/html/ping b/e2e-tests/oauth-flow/rfc021/node-B/html/ping new file mode 100644 index 0000000000..ed53c21358 --- /dev/null +++ b/e2e-tests/oauth-flow/rfc021/node-B/html/ping @@ -0,0 +1 @@ +pong \ No newline at end of file diff --git a/e2e-tests/oauth-flow/rfc021/node-B/nginx.conf b/e2e-tests/oauth-flow/rfc021/node-B/nginx.conf new file mode 100644 index 0000000000..55b6234507 --- /dev/null +++ b/e2e-tests/oauth-flow/rfc021/node-B/nginx.conf @@ -0,0 +1,47 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log debug; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + keepalive_timeout 65; + + include /etc/nginx/conf.d/*.conf; + + upstream nodeB-backend { + server nodeB-backend:1323; + } + + server { + server_name nodeB; + listen 443 ssl; + http2 on; + ssl_certificate /etc/nginx/ssl/server.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_client_certificate /etc/nginx/ssl/truststore.pem; + ssl_verify_client optional; + ssl_verify_depth 1; + ssl_protocols TLSv1.3; + + location / { + proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert; + proxy_pass http://nodeB-backend; + } + } +} diff --git a/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml b/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml index 48297172e5..a1d33171fa 100644 --- a/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml +++ b/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml @@ -17,4 +17,3 @@ tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem certkeyfile: /opt/nuts/certificate-and-key.pem - diff --git a/e2e-tests/oauth-flow/rfc021/run-test.sh b/e2e-tests/oauth-flow/rfc021/run-test.sh index 64116510f8..6e68322cd3 100755 --- a/e2e-tests/oauth-flow/rfc021/run-test.sh +++ b/e2e-tests/oauth-flow/rfc021/run-test.sh @@ -19,31 +19,17 @@ echo "------------------------------------" echo "Registering vendors..." echo "------------------------------------" # Register Vendor A -VENDOR_A_DIDDOC=$(docker compose exec nodeA-backend nuts vdr create-did) +VENDOR_A_DIDDOC=$(docker compose exec nodeA-backend nuts vdr create-did --v2) VENDOR_A_DID=$(echo $VENDOR_A_DIDDOC | jq -r .id) echo Vendor A DID: $VENDOR_A_DID -# Add assertionMethod -VENDOR_A_KEYID=$(echo $VENDOR_A_DIDDOC | jq -r '.verificationMethod[0].id') -VENDOR_A_DIDDOC=$(echo $VENDOR_A_DIDDOC | jq ". |= . + {assertionMethod: [\"${VENDOR_A_KEYID}\"]}") -# Perform update -echo $VENDOR_A_DIDDOC > ./node-A/data/updated-did.json -DIDDOC_HASH=$(docker compose exec nodeA-backend nuts vdr resolve $VENDOR_A_DID --metadata | jq -r .hash) -docker compose exec nodeA-backend nuts vdr update "${VENDOR_A_DID}" "${DIDDOC_HASH}" /opt/nuts/data/updated-did.json # Register Vendor B -VENDOR_B_DIDDOC=$(docker compose exec nodeB nuts vdr create-did) +VENDOR_B_DIDDOC=$(docker compose exec nodeB-backend nuts vdr create-did --v2) VENDOR_B_DID=$(echo $VENDOR_B_DIDDOC | jq -r .id) echo Vendor B DID: $VENDOR_B_DID -# Add assertionMethod -VENDOR_B_KEYID=$(echo $VENDOR_B_DIDDOC | jq -r '.verificationMethod[0].id') -VENDOR_B_DIDDOC=$(echo $VENDOR_B_DIDDOC | jq ". |= . + {assertionMethod: [\"${VENDOR_B_KEYID}\"]}") -# Perform update -echo $VENDOR_B_DIDDOC > ./node-B/data/updated-did.json -DIDDOC_HASH=$(docker compose exec nodeB nuts vdr resolve $VENDOR_B_DID --metadata | jq -r .hash) -docker compose exec nodeB nuts vdr update "${VENDOR_B_DID}" "${DIDDOC_HASH}" /opt/nuts/data/updated-did.json # Issue NutsOrganizationCredential for Vendor B -REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"visibility\": \"public\"}" +REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"publishToNetwork\": false}" RESPONSE=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") if echo $RESPONSE | grep -q "VerifiableCredential"; then echo "VC issued" @@ -53,14 +39,21 @@ else exitWithDockerLogs 1 fi +RESPONSE=$(echo $RESPONSE | curl -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/holder/${VENDOR_B_DID}/vc -H "Content-Type:application/json") +if echo $RESPONSE == ""; then + echo "VC stored in wallet" +else + echo "FAILED: Could not load NutsOrganizationCredential in node-B wallet" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + echo "---------------------------------------" echo "Perform OAuth 2.0 rfc021 flow..." echo "---------------------------------------" # Request access token # Create DID for A with :nuts: replaced with :web: -VENDOR_A_DID_WEB=$(echo $VENDOR_A_DID | sed 's/:nuts/:web:nodeA:iam/') -VENDOR_B_DID_WEB=$(echo $VENDOR_B_DID | sed 's/:nuts/:web:nodeB:iam/') -REQUEST="{\"verifier\":\"${VENDOR_A_DID_WEB}\",\"scope\":\"test\"}" +REQUEST="{\"verifier\":\"${VENDOR_A_DID}\",\"scope\":\"test\"}" RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:21323/internal/auth/v2/$VENDOR_B_DID/request-access-token -H "Content-Type:application/json" -v) #if echo $RESPONSE | grep -q "access_token"; then # echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/' > ./node-B/data/accesstoken.txt diff --git a/echo/echo_integration_test.go b/echo/echo_integration_test.go index bd086e3964..cc1e2c06e9 100644 --- a/echo/echo_integration_test.go +++ b/echo/echo_integration_test.go @@ -35,9 +35,6 @@ import ( // TestStatusCodes tests if the returned errors from the API implementations are correctly translated to status codes func TestStatusCodes(t *testing.T) { - hook := logTest.NewGlobal() - baseUrl, _ := node.StartServer(t) - type operation struct { module string operation string @@ -45,6 +42,9 @@ func TestStatusCodes(t *testing.T) { body interface{} } t.Run("404s", func(t *testing.T) { + hook := logTest.NewGlobal() + baseUrl, _ := node.StartServer(t) + testCases := []operation{ {module: "Auth", operation: "GetSignSessionStatus", url: "/internal/auth/v1/signature/session/1"}, {module: "Auth", operation: "GetContractByType", url: "/public/auth/v1/contract/1"}, @@ -66,6 +66,9 @@ func TestStatusCodes(t *testing.T) { } }) t.Run("400s", func(t *testing.T) { + hook := logTest.NewGlobal() + baseUrl, _ := node.StartServer(t) + testCases := []operation{ {module: "Crypto", operation: "SignJwt", url: "/internal/crypto/v1/sign_jwt", body: map[string]interface{}{"kid": "fpp", "claims": map[string]interface{}{"foo": "bar"}}}, {module: "Network", operation: "GetTransaction", url: "/internal/network/v1/transaction/invalidhash"}, diff --git a/storage/engine.go b/storage/engine.go index 17c1197c3a..2ede3808ff 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -73,9 +73,6 @@ func (e *engine) Name() string { } func (e *engine) Start() error { - if err := e.initSQLDatabase(); err != nil { - return fmt.Errorf("failed to initialize SQL database: %w", err) - } return nil } @@ -138,6 +135,11 @@ func (e *engine) Configure(config core.ServerConfig) error { return fmt.Errorf("unable to configure BBolt database: %w", err) } e.databases = append(e.databases, bboltDB) + + if err := e.initSQLDatabase(); err != nil { + return fmt.Errorf("failed to initialize SQL database: %w", err) + } + return nil } diff --git a/storage/engine_test.go b/storage/engine_test.go index befd3bae6f..f4a6ceb4b9 100644 --- a/storage/engine_test.go +++ b/storage/engine_test.go @@ -115,8 +115,7 @@ func Test_engine_sqlDatabase(t *testing.T) { dataDir := io.TestDirectory(t) require.NoError(t, os.Remove(dataDir)) e := New() - require.NoError(t, e.Configure(core.ServerConfig{Datadir: dataDir})) - err := e.Start() + err := e.Configure(core.ServerConfig{Datadir: dataDir}) assert.EqualError(t, err, "failed to initialize SQL database: unable to open database file") }) t.Run("nothing to migrate (already migrated)", func(t *testing.T) { diff --git a/storage/sql_migrations/3_didweb.down.sql b/storage/sql_migrations/3_didweb.down.sql new file mode 100644 index 0000000000..e29815b393 --- /dev/null +++ b/storage/sql_migrations/3_didweb.down.sql @@ -0,0 +1,3 @@ +drop table vdr_didweb; +drop table vdr_didweb_service; +drop table vdr_didweb_verificationmethod; \ No newline at end of file diff --git a/storage/sql_migrations/3_didweb.up.sql b/storage/sql_migrations/3_didweb.up.sql new file mode 100644 index 0000000000..3eb05f1a1b --- /dev/null +++ b/storage/sql_migrations/3_didweb.up.sql @@ -0,0 +1,21 @@ +-- this table is used to store the did:web +create table vdr_didweb +( + -- did is the fully qualified did:web + did varchar(500) not null, + primary key (did) +); + +-- this table is used to store the verification methods for a did:web +create table vdr_didweb_verificationmethod +( + -- id is the unique id of the verification method as it appears in the DID document. + id varchar(500) not null, + -- did references the containing did:web + did varchar(255) not null, + -- data is a JSON object containing the verification method data, e.g. the public key. + -- When producing the verificationMethod, data is used as JSON base object and the id and type are added. + data blob not null, + primary key (did, id), + foreign key (did) references vdr_didweb (did) on delete cascade +); \ No newline at end of file diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index 1240dece68..003fc059cc 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -327,7 +327,6 @@ func (w *Wrapper) LoadVC(ctx context.Context, request LoadVCRequestObject) (Load if err != nil { return nil, core.InvalidInputError("invalid holder did: %w", err) } - if request.Body == nil { return nil, core.InvalidInputError("missing credential in body") } diff --git a/vcr/test.go b/vcr/test.go index 1e0127a916..ded0ad1c9d 100644 --- a/vcr/test.go +++ b/vcr/test.go @@ -60,8 +60,8 @@ func NewTestVCRContext(t *testing.T, keyStore crypto.KeyStore) TestVCRContext { storageEngine := storage.NewTestStorageEngine(t) networkInstance := network.NewTestNetworkInstance(t) eventManager := events.NewTestManager(t) - vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager) - err := vdrInstance.Configure(core.ServerConfig{}) + vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager, storageEngine) + err := vdrInstance.Configure(core.ServerConfig{URL: "http://nuts.test"}) require.NoError(t, err) newInstance := NewVCRInstance( ctx.KeyStore, @@ -93,10 +93,10 @@ func NewTestVCRInstance(t *testing.T) *vcr { keyStore := crypto.NewMemoryCryptoInstance() eventManager := events.NewTestManager(t) networkInstance := network.NewNetworkInstance(network.TestNetworkConfig(), didStore, keyStore, eventManager, storageEngine.GetProvider("network"), nil) - serverCfg := core.TestServerConfig(core.ServerConfig{Datadir: testDirectory}) + serverCfg := core.TestServerConfig(core.ServerConfig{Datadir: testDirectory, URL: "http://nuts.test"}) _ = networkInstance.Configure(serverCfg) - vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager) - err := vdrInstance.Configure(core.ServerConfig{}) + vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager, storageEngine) + err := vdrInstance.Configure(serverCfg) if err != nil { t.Fatal(err) } @@ -124,7 +124,7 @@ func NewTestVCRInstanceInDir(t *testing.T, testDirectory string) *vcr { storageEngine := storage.NewTestStorageEngineInDir(testDirectory) networkInstance := network.NewTestNetworkInstance(t) eventManager := events.NewTestManager(t) - vdrInstance := vdr.NewVDR(nil, networkInstance, didStore, eventManager) + vdrInstance := vdr.NewVDR(nil, networkInstance, didStore, eventManager, storageEngine) err := vdrInstance.Configure(core.ServerConfig{}) if err != nil { t.Fatal(err) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index e735aa8f5e..b6ee8020a4 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -275,7 +275,7 @@ func testCredential() vc.VerifiableCredential { func registerDID(t *testing.T, system *core.System) did.DID { vdrService := system.FindEngineByName("vdr").(vdr.VDR) ctx := audit.TestContext() - didDocument, _, err := vdrService.Create(ctx, didnuts.DefaultCreationOptions()) + didDocument, _, err := vdrService.Create(ctx, didnuts.MethodName, didnuts.DefaultCreationOptions()) require.NoError(t, err) return didDocument.ID diff --git a/vdr/api/v1/api.go b/vdr/api/v1/api.go index a44a297bca..2730361dc2 100644 --- a/vdr/api/v1/api.go +++ b/vdr/api/v1/api.go @@ -130,7 +130,7 @@ func (a *Wrapper) CreateDID(ctx context.Context, request CreateDIDRequestObject) options.SelfControl = *request.Body.SelfControl } - doc, _, err := a.VDR.Create(ctx, options) + doc, _, err := a.VDR.Create(ctx, didnuts.MethodName, options) // if this operation leads to an error, it may return a 500 if err != nil { return nil, err diff --git a/vdr/api/v1/api_test.go b/vdr/api/v1/api_test.go index 867af71f8d..14a63777e2 100644 --- a/vdr/api/v1/api_test.go +++ b/vdr/api/v1/api_test.go @@ -1,16 +1,20 @@ /* * Nuts node * Copyright (C) 2021 Nuts community + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. + * * You should have received a copy of the GNU General Public License * along with this program. If not, see . + * */ package v1 @@ -18,18 +22,18 @@ package v1 import ( "context" "errors" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/vdr" - "github.com/nuts-foundation/nuts-node/vdr/didnuts" - "github.com/nuts-foundation/nuts-node/vdr/management" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" "testing" "time" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/vdr" + "github.com/nuts-foundation/nuts-node/vdr/didnuts" + "github.com/nuts-foundation/nuts-node/vdr/management" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -44,7 +48,7 @@ func TestWrapper_CreateDID(t *testing.T) { t.Run("ok - defaults", func(t *testing.T) { ctx := newMockContext(t) request := DIDCreateRequest{} - ctx.vdr.EXPECT().Create(gomock.Any(), gomock.Any()).Return(didDoc, nil, nil) + ctx.vdr.EXPECT().Create(gomock.Any(), didnuts.MethodName, gomock.Any()).Return(didDoc, nil, nil) response, err := ctx.client.CreateDID(nil, CreateDIDRequestObject{Body: &request}) @@ -67,7 +71,7 @@ func TestWrapper_CreateDID(t *testing.T) { SelfControl: new(bool), Controllers: &controllers, } - ctx.vdr.EXPECT().Create(gomock.Any(), gomock.Any()).Return(didDoc, nil, nil) + ctx.vdr.EXPECT().Create(gomock.Any(), didnuts.MethodName, gomock.Any()).Return(didDoc, nil, nil) response, err := ctx.client.CreateDID(nil, CreateDIDRequestObject{Body: &request}) @@ -92,7 +96,7 @@ func TestWrapper_CreateDID(t *testing.T) { t.Run("error - create fails", func(t *testing.T) { ctx := newMockContext(t) request := DIDCreateRequest{} - ctx.vdr.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil, nil, errors.New("b00m!")) + ctx.vdr.EXPECT().Create(gomock.Any(), didnuts.MethodName, gomock.Any()).Return(nil, nil, errors.New("b00m!")) response, err := ctx.client.CreateDID(nil, CreateDIDRequestObject{Body: &request}) diff --git a/vdr/api/v1/client_test.go b/vdr/api/v1/client_test.go index 5d9cc4bf9a..fb0313d8bd 100644 --- a/vdr/api/v1/client_test.go +++ b/vdr/api/v1/client_test.go @@ -49,19 +49,23 @@ func TestHTTPClient_Create(t *testing.T) { require.NoError(t, err) assert.NotNil(t, doc) }) - t.Run("error - server error", func(t *testing.T) { s := httptest.NewServer(&http2.Handler{StatusCode: http.StatusInternalServerError, ResponseData: ""}) c := getClient(s.URL) _, err := c.Create(DIDCreateRequest{}) assert.Error(t, err) }) - t.Run("error - wrong address", func(t *testing.T) { c := getClient("not_an_address") _, err := c.Create(DIDCreateRequest{}) assert.Error(t, err) }) + t.Run("error - invalid response", func(t *testing.T) { + s := httptest.NewServer(&http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"}) + c := getClient(s.URL) + _, err := c.Create(DIDCreateRequest{}) + assert.Error(t, err) + }) } func TestHttpClient_Get(t *testing.T) { diff --git a/vdr/api/v2/api.go b/vdr/api/v2/api.go index 06300dc564..7624edb885 100644 --- a/vdr/api/v2/api.go +++ b/vdr/api/v2/api.go @@ -1,6 +1,6 @@ /* * Nuts node - * Copyright (C) 2021 Nuts community + * 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 @@ -21,8 +21,15 @@ package v2 import ( "context" + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vdr" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "github.com/nuts-foundation/nuts-node/vdr/management" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "net/http" ) var _ StrictServerInterface = (*Wrapper)(nil) @@ -33,14 +40,45 @@ type Wrapper struct { VDR vdr.VDR } -func (w Wrapper) ResolveStatusCode(err error) int { - //TODO implement me - panic("implement me") +// 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{ + resolver.ErrNotFound: http.StatusNotFound, + resolver.ErrDIDNotManagedByThisNode: http.StatusForbidden, + resolver.ErrDuplicateService: http.StatusBadRequest, + did.ErrInvalidDID: http.StatusBadRequest, + }) } -func (w Wrapper) CreateDID(ctx context.Context, request CreateDIDRequestObject) (CreateDIDResponseObject, error) { - //TODO implement me - panic("implement me") +func (a *Wrapper) Routes(router core.EchoRouter) { + RegisterHandlers(router, NewStrictHandler(a, []StrictMiddlewareFunc{ + 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, vdr.ModuleName) + ctx.Set(core.StatusCodeResolverContextKey, a) + return f(ctx, request) + } + }, + func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { + return audit.StrictMiddleware(f, vdr.ModuleName, operationID) + }, + })) +} + +func (w Wrapper) CreateDID(ctx context.Context, _ CreateDIDRequestObject) (CreateDIDResponseObject, error) { + options := management.DIDCreationOptions{ + KeyFlags: management.AssertionMethodUsage | management.CapabilityInvocationUsage | management.KeyAgreementUsage | management.AuthenticationUsage | management.CapabilityDelegationUsage, + SelfControl: true, + } + + doc, _, err := w.VDR.Create(ctx, didweb.MethodName, options) + // if this operation leads to an error, it may return a 500 + if err != nil { + return nil, err + } + + return CreateDID200JSONResponse(*doc), nil } func (w Wrapper) DeleteDID(ctx context.Context, request DeleteDIDRequestObject) (DeleteDIDResponseObject, error) { diff --git a/vdr/api/v2/api_test.go b/vdr/api/v2/api_test.go new file mode 100644 index 0000000000..c4cb2e85dd --- /dev/null +++ b/vdr/api/v2/api_test.go @@ -0,0 +1,87 @@ +/* + * Nuts node + * 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 v2 + +import ( + "context" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "testing" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/vdr" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestWrapper_CreateDID(t *testing.T) { + id := did.MustParseDID("did:web:example.com:iam:1") + didDoc := &did.Document{ + ID: id, + } + + t.Run("ok - defaults", func(t *testing.T) { + ctx := newMockContext(t) + ctx.vdr.EXPECT().Create(gomock.Any(), didweb.MethodName, gomock.Any()).Return(didDoc, nil, nil) + + response, err := ctx.client.CreateDID(nil, CreateDIDRequestObject{}) + + require.NoError(t, err) + assert.Equal(t, id, response.(CreateDID200JSONResponse).ID) + }) + + t.Run("error - create fails", func(t *testing.T) { + ctx := newMockContext(t) + ctx.vdr.EXPECT().Create(gomock.Any(), didweb.MethodName, gomock.Any()).Return(nil, nil, assert.AnError) + + response, err := ctx.client.CreateDID(nil, CreateDIDRequestObject{}) + + assert.Error(t, err) + assert.Nil(t, response) + }) +} + +type mockContext struct { + ctrl *gomock.Controller + vdr *vdr.MockVDR + didResolver *resolver.MockDIDResolver + client *Wrapper + requestCtx context.Context +} + +func newMockContext(t *testing.T) mockContext { + t.Helper() + ctrl := gomock.NewController(t) + didResolver := resolver.NewMockDIDResolver(ctrl) + vdr := vdr.NewMockVDR(ctrl) + vdr.EXPECT().Resolver().Return(didResolver).AnyTimes() + client := &Wrapper{VDR: vdr} + requestCtx := audit.TestContext() + + return mockContext{ + ctrl: ctrl, + vdr: vdr, + didResolver: didResolver, + client: client, + requestCtx: requestCtx, + } +} diff --git a/vdr/api/v2/client.go b/vdr/api/v2/client.go new file mode 100644 index 0000000000..f93c5cb3b4 --- /dev/null +++ b/vdr/api/v2/client.go @@ -0,0 +1,71 @@ +/* + * Nuts node + * 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 v2 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/core" +) + +// HTTPClient holds the server address and other basic settings for the http client +type HTTPClient struct { + core.ClientConfig + TokenGenerator core.AuthorizationTokenGenerator +} + +func (hb HTTPClient) client() ClientInterface { + response, err := NewClientWithResponses(hb.GetAddress(), WithHTTPClient(core.MustCreateHTTPClient(hb.ClientConfig, hb.TokenGenerator))) + if err != nil { + panic(err) + } + return response +} + +// Create calls the server and creates a new DID Document +// It does not parse a custom id but depends on the server to generate one +func (hb HTTPClient) Create() (*did.Document, error) { + ctx := context.Background() + + if response, err := hb.client().CreateDID(ctx, CreateDIDJSONRequestBody{}); err != nil { + return nil, err + } else if err := core.TestResponseCode(http.StatusOK, response); err != nil { + return nil, err + } else { + return readDIDDocument(response.Body) + } +} + +func readDIDDocument(reader io.Reader) (*did.Document, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("unable to read DID Document response: %w", err) + } + document := did.Document{} + if err = json.Unmarshal(data, &document); err != nil { + return nil, fmt.Errorf("unable to unmarshal DID Document response: %w, %s", err, string(data)) + } + return &document, nil +} diff --git a/vdr/api/v2/client_test.go b/vdr/api/v2/client_test.go new file mode 100644 index 0000000000..b22a7dde50 --- /dev/null +++ b/vdr/api/v2/client_test.go @@ -0,0 +1,75 @@ +/* + * Nuts node + * 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 v2 + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/core" + http2 "github.com/nuts-foundation/nuts-node/test/http" + "github.com/nuts-foundation/nuts-node/vdr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTTPClient_Create(t *testing.T) { + didDoc := did.Document{ + ID: vdr.TestDIDA, + } + + t.Run("ok", func(t *testing.T) { + s := httptest.NewServer(&http2.Handler{StatusCode: http.StatusOK, ResponseData: didDoc}) + c := getClient(s.URL) + doc, err := c.Create() + require.NoError(t, err) + assert.NotNil(t, doc) + }) + + t.Run("error - server error", func(t *testing.T) { + s := httptest.NewServer(&http2.Handler{StatusCode: http.StatusInternalServerError, ResponseData: ""}) + c := getClient(s.URL) + _, err := c.Create() + assert.Error(t, err) + }) + + t.Run("error - wrong address", func(t *testing.T) { + c := getClient("not_an_address") + _, err := c.Create() + assert.Error(t, err) + }) +} + +type errReader struct{} + +func (e errReader) Read(_ []byte) (n int, err error) { + return 0, assert.AnError +} + +func getClient(url string) *HTTPClient { + return &HTTPClient{ + ClientConfig: core.ClientConfig{ + Address: url, Timeout: time.Second, + }, + } +} diff --git a/vdr/cmd/cmd.go b/vdr/cmd/cmd.go index 0b3a3134c7..6ff996c9ae 100644 --- a/vdr/cmd/cmd.go +++ b/vdr/cmd/cmd.go @@ -23,9 +23,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/vdr/didnuts" - "github.com/nuts-foundation/nuts-node/vdr/management" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "io" "os" "strings" @@ -33,6 +30,10 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" 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/management" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -75,14 +76,25 @@ func createCmd() *cobra.Command { Controllers: new([]string), SelfControl: new(bool), } + // todo should become default + var useV2 bool result := &cobra.Command{ Use: "create-did", Short: "Registers a new DID", + Long: "When using the V2 API, a web:did will be created. All the other options are ignored for a web:did.", Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { clientConfig := core.NewClientConfigForCommand(cmd) - doc, err := httpClient(clientConfig).Create(createRequest) + var ( + doc *did.Document + err error + ) + if useV2 { + doc, err = httpClientV2(clientConfig).Create() + } else { + doc, err = httpClient(clientConfig).Create(createRequest) + } if err != nil { return fmt.Errorf("unable to create new DID: %v", err) } @@ -106,6 +118,7 @@ func createCmd() *cobra.Command { result.Flags().BoolVar(createRequest.CapabilityInvocation, "capabilityInvocation", defs.KeyFlags.Is(management.CapabilityInvocationUsage), setUsage(defs.KeyFlags.Is(management.CapabilityInvocationUsage), "Pass '%t' to %s capabilityInvocation capabilities.")) result.Flags().BoolVar(createRequest.KeyAgreement, "keyAgreement", defs.KeyFlags.Is(management.KeyAgreementUsage), setUsage(defs.KeyFlags.Is(management.KeyAgreementUsage), "Pass '%t' to %s keyAgreement capabilities.")) result.Flags().BoolVar(createRequest.SelfControl, "selfControl", defs.SelfControl, setUsage(defs.SelfControl, "Pass '%t' to %s DID Document control.")) + result.Flags().BoolVar(&useV2, "v2", false, "Pass 'true' to use the V2 API and create a web:did.") result.Flags().StringSliceVar(createRequest.Controllers, "controllers", []string{}, "Comma-separated list of DIDs that can control the generated DID Document.") return result @@ -385,3 +398,10 @@ func httpClient(config core.ClientConfig) api.HTTPClient { ClientConfig: config, } } + +// httpClientV2 creates a remote client using the V2 API +func httpClientV2(config core.ClientConfig) apiv2.HTTPClient { + return apiv2.HTTPClient{ + ClientConfig: config, + } +} diff --git a/vdr/cmd/cmd_test.go b/vdr/cmd/cmd_test.go index 5e1f216f9e..6fe2355d2e 100644 --- a/vdr/cmd/cmd_test.go +++ b/vdr/cmd/cmd_test.go @@ -73,7 +73,7 @@ func TestEngine_Command(t *testing.T) { return command } - newCmdWithServer := func(t *testing.T, handler *http2.Handler) *cobra.Command { + newCmdWithServer := func(t *testing.T, handler http.Handler) *cobra.Command { cmd := newCmd(t) s := httptest.NewServer(handler) t.Setenv("NUTS_ADDRESS", s.URL) @@ -96,7 +96,23 @@ func TestEngine_Command(t *testing.T) { assert.Empty(t, errBuf.Bytes()) assert.NoError(t, err) }) + t.Run("ok - v2", func(t *testing.T) { + cmd := newCmdWithServer(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, "/internal/vdr/v2/did", request.URL.Path) + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(exampleDIDDocument) + _, _ = writer.Write(bytes) + })) + cmd.SetArgs([]string{"create-did", "--v2"}) + cmd.PersistentFlags().AddFlagSet(core.ClientConfigFlags()) + err := cmd.Execute() + require.NoError(t, err) + document := did.Document{} + err = json.Unmarshal(buf.Bytes(), &document) + assert.Empty(t, errBuf.Bytes()) + assert.NoError(t, err) + }) t.Run("error - server error", func(t *testing.T) { cmd := newCmdWithServer(t, &http2.Handler{StatusCode: http.StatusInternalServerError, ResponseData: "b00m!"}) cmd.SetArgs([]string{"create-did"}) diff --git a/vdr/didweb/manager.go b/vdr/didweb/manager.go new file mode 100644 index 0000000000..8e70c638a4 --- /dev/null +++ b/vdr/didweb/manager.go @@ -0,0 +1,139 @@ +/* + * 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 didweb + +import ( + "context" + crypt "crypto" + "errors" + "fmt" + "github.com/google/uuid" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vdr/management" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "gorm.io/gorm" + "net/url" +) + +var _ management.DocCreator = (*Manager)(nil) +var _ resolver.DIDResolver = (*Manager)(nil) + +// NewManager creates a new Manager to create and update did:web DID documents. +func NewManager(baseURL url.URL, keyStore crypto.KeyStore, db *gorm.DB) *Manager { + return &Manager{ + store: &sqlStore{db: db}, + baseURL: baseURL, + keyStore: keyStore, + } +} + +// Manager creates and updates did:web documents +type Manager struct { + baseURL url.URL + store *sqlStore + keyStore crypto.KeyStore +} + +// Create creates a new did:web document. +func (m Manager) Create(ctx context.Context, _ management.DIDCreationOptions) (*did.Document, crypto.Key, error) { + return m.create(ctx, uuid.NewString()) +} + +func (m Manager) create(ctx context.Context, mostSignificantBits string) (*did.Document, crypto.Key, error) { + newDID, err := URLToDID(*m.baseURL.JoinPath(mostSignificantBits)) + if err != nil { + return nil, nil, err + } + verificationMethodKey, verificationMethod, err := m.createVerificationMethod(ctx, *newDID) + if err != nil { + return nil, nil, err + } + if err := m.store.create(*newDID, *verificationMethod); err != nil { + return nil, nil, fmt.Errorf("store new DID: %w", err) + } + + document := buildDocument(*newDID, []did.VerificationMethod{*verificationMethod}) + return &document, verificationMethodKey, nil +} + +func (m Manager) createVerificationMethod(ctx context.Context, ownerDID did.DID) (crypto.Key, *did.VerificationMethod, error) { + verificationMethodID := did.DIDURL{ + DID: ownerDID, + Fragment: "0", // TODO: Which fragment should we use? Thumbprint, UUID, index, etc... + } + verificationMethodKey, err := m.keyStore.New(ctx, func(key crypt.PublicKey) (string, error) { + return verificationMethodID.String(), nil + }) + if err != nil { + return nil, nil, err + } + verificationMethod, err := did.NewVerificationMethod(verificationMethodID, ssi.JsonWebKey2020, ownerDID, verificationMethodKey.Public()) + if err != nil { + return nil, nil, err + } + return verificationMethodKey, verificationMethod, nil +} + +// Resolve returns the did:web document for the given DID, if it is managed by this node. +func (m Manager) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Document, *resolver.DocumentMetadata, error) { + vms, err := m.store.get(id) + if err != nil { + return nil, nil, err + } + document := buildDocument(id, vms) + return &document, &resolver.DocumentMetadata{}, nil +} + +func (m Manager) IsOwner(_ context.Context, id did.DID) (bool, error) { + _, err := m.store.get(id) + if errors.Is(err, resolver.ErrNotFound) { + return false, nil + } + return err == nil, err +} + +func (m Manager) ListOwned(_ context.Context) ([]did.DID, error) { + return m.store.list() +} + +func buildDocument(subject did.DID, verificationMethods []did.VerificationMethod) did.Document { + var vms []*did.VerificationMethod + for _, verificationMethod := range verificationMethods { + vms = append(vms, &verificationMethod) + } + + document := did.Document{ + Context: []interface{}{ + ssi.MustParseURI(jsonld.Jws2020Context), + did.DIDContextV1URI(), + }, + ID: subject, + } + for _, verificationMethod := range verificationMethods { + document.AddAssertionMethod(&verificationMethod) + document.AddAuthenticationMethod(&verificationMethod) + document.AddKeyAgreement(&verificationMethod) + document.AddCapabilityDelegation(&verificationMethod) + document.AddCapabilityInvocation(&verificationMethod) + } + return document +} diff --git a/vdr/didweb/manager_test.go b/vdr/didweb/manager_test.go new file mode 100644 index 0000000000..09f1306bba --- /dev/null +++ b/vdr/didweb/manager_test.go @@ -0,0 +1,207 @@ +/* + * 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 didweb + +import ( + "crypto" + "encoding/json" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/audit" + nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vdr/management" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "net/url" + "testing" +) + +func TestManager_Create(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + baseURL, _ := url.Parse("https://example.com") + + const keyJSON = `{ + "crv": "P-256", + "kty": "EC", + "x": "4VT-BXoTel3lvlwJRFFgN0XhWeSdziIzgHqE_J-o42k", + "y": "yXwrpCkfKm44IcAI4INk_flMwFULonJeo595_g-dwwE" + }` + keyAsJWK, err := jwk.ParseKey([]byte(keyJSON)) + require.NoError(t, err) + var publicKey crypto.PublicKey + require.NoError(t, keyAsJWK.Raw(&publicKey)) + + t.Run("ok", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + keyStore := nutsCrypto.NewMockKeyStore(ctrl) + keyStore.EXPECT().New(gomock.Any(), gomock.Any()).Return(nutsCrypto.TestPublicKey{ + PublicKey: publicKey, + }, nil) + m := NewManager(*baseURL, keyStore, storageEngine.GetSQLDatabase()) + + document, key, err := m.create(audit.TestContext(), "e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4") + require.NoError(t, err) + require.NotNil(t, document) + require.NotNil(t, key) + + const expected = ` +{ + "@context": [ + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json", + "https://www.w3.org/ns/did/v1" + ], + "assertionMethod": [ + "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4#0" + ], + "authentication": [ + "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4#0" + ], + "capabilityDelegation": [ + "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4#0" + ], + "capabilityInvocation": [ + "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4#0" + ], + "id": "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4", + "keyAgreement": [ + "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4#0" + ], + "verificationMethod": [ + { + "controller": "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4", + "id": "did:web:example.com:e9d4b80d-59eb-4f35-ada8-c75f6e14bbc4#0", + "publicKeyJwk": { + "crv": "P-256", + "kty": "EC", + "x": "4VT-BXoTel3lvlwJRFFgN0XhWeSdziIzgHqE_J-o42k", + "y": "yXwrpCkfKm44IcAI4INk_flMwFULonJeo595_g-dwwE" + }, + "type": "JsonWebKey2020" + } + ] +}` + actual, _ := json.Marshal(document) + assert.JSONEq(t, expected, string(actual)) + }) +} + +func TestManager_IsOwner(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + baseURL, _ := url.Parse("https://example.com") + id := did.MustParseDID("did:web:example.com:1234") + + t.Run("not owned (empty store)", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + + owned, err := m.IsOwner(audit.TestContext(), id) + require.NoError(t, err) + assert.False(t, owned) + }) + t.Run("not owned (other DID)", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + _, _, err := m.Create(audit.TestContext(), management.DIDCreationOptions{}) + require.NoError(t, err) + + owned, err := m.IsOwner(audit.TestContext(), id) + require.NoError(t, err) + assert.False(t, owned) + }) + t.Run("owned", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + document, _, err := m.Create(audit.TestContext(), management.DIDCreationOptions{}) + require.NoError(t, err) + + owned, err := m.IsOwner(audit.TestContext(), document.ID) + require.NoError(t, err) + assert.True(t, owned) + }) +} + +func TestManager_ListOwned(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + baseURL, _ := url.Parse("https://example.com") + + t.Run("empty store", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + + dids, err := m.ListOwned(audit.TestContext()) + require.NoError(t, err) + assert.Empty(t, dids) + }) + t.Run("single DID", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + document, _, err := m.Create(audit.TestContext(), management.DIDCreationOptions{}) + require.NoError(t, err) + + dids, err := m.ListOwned(audit.TestContext()) + require.NoError(t, err) + assert.Equal(t, []did.DID{document.ID}, dids) + }) + t.Run("multiple DIDs", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + document1, _, err := m.Create(audit.TestContext(), management.DIDCreationOptions{}) + require.NoError(t, err) + document2, _, err := m.Create(audit.TestContext(), management.DIDCreationOptions{}) + require.NoError(t, err) + + dids, err := m.ListOwned(audit.TestContext()) + require.NoError(t, err) + assert.ElementsMatch(t, []did.DID{document1.ID, document2.ID}, dids) + }) +} + +func TestManager_Resolve(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + baseURL, _ := url.Parse("https://example.com") + + t.Run("not found", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + + document, _, err := m.Resolve(did.MustParseDID("did:web:example.com:1234"), nil) + require.ErrorIs(t, err, resolver.ErrNotFound) + assert.Nil(t, document) + }) + t.Run("ok", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + m := NewManager(*baseURL, nutsCrypto.NewMemoryCryptoInstance(), storageEngine.GetSQLDatabase()) + document, _, err := m.Create(audit.TestContext(), management.DIDCreationOptions{}) + require.NoError(t, err) + expected, _ := document.MarshalJSON() + + resolvedDocument, _, err := m.Resolve(document.ID, nil) + require.NoError(t, err) + actual, _ := resolvedDocument.MarshalJSON() + assert.JSONEq(t, string(expected), string(actual)) + }) +} diff --git a/vdr/didweb/store.go b/vdr/didweb/store.go new file mode 100644 index 0000000000..549c777c92 --- /dev/null +++ b/vdr/didweb/store.go @@ -0,0 +1,122 @@ +/* + * 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 didweb + +import ( + "encoding/json" + "errors" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +type store interface { + create(did did.DID, methods ...did.VerificationMethod) error + get(did did.DID) ([]did.VerificationMethod, error) + list() ([]did.DID, error) +} + +var _ schema.Tabler = (*sqlDID)(nil) + +type sqlDID struct { + Did string `gorm:"primaryKey"` + VerificationMethods []sqlVerificationMethod `gorm:"foreignKey:Did;references:Did"` +} + +func (d sqlDID) TableName() string { + return "vdr_didweb" +} + +var _ schema.Tabler = (*sqlVerificationMethod)(nil) + +type sqlVerificationMethod struct { + ID string `gorm:"primaryKey"` + Did string `gorm:"primaryKey"` + Data []byte +} + +func (v sqlVerificationMethod) TableName() string { + return "vdr_didweb_verificationmethod" +} + +var _ store = (*sqlStore)(nil) + +type sqlStore struct { + db *gorm.DB +} + +func (s *sqlStore) create(did did.DID, methods ...did.VerificationMethod) error { + record := &sqlDID{Did: did.String()} + for _, method := range methods { + data, _ := json.Marshal(method) + record.VerificationMethods = append(record.VerificationMethods, sqlVerificationMethod{ + ID: method.ID.String(), + Did: record.Did, + Data: data, + }) + } + return s.db.Create(record).Error +} + +func (s *sqlStore) get(id did.DID) ([]did.VerificationMethod, error) { + var record sqlDID + err := s.db.Model(&sqlDID{}).Where("did = ?", id.String()). + Preload("VerificationMethods"). + First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, resolver.ErrNotFound + } + if err != nil { + return nil, err + } + var result []did.VerificationMethod + for _, curr := range record.VerificationMethods { + var method did.VerificationMethod + if err := json.Unmarshal(curr.Data, &method); err != nil { + return nil, err + } + result = append(result, method) + vmID, err := did.ParseDIDURL(curr.ID) + if err != nil { + // weird + return nil, err + } + method.ID = *vmID + } + return result, nil +} + +// list returns all DIDs in the store. +func (s *sqlStore) list() ([]did.DID, error) { + var list []sqlDID + err := s.db.Model(&sqlDID{}).Select("did").Find(&list).Error + if err != nil { + return nil, err + } + var result []did.DID + for _, curr := range list { + parsed, err := did.ParseDID(curr.Did) + if err != nil { + return nil, err + } + result = append(result, *parsed) + } + return result, nil +} diff --git a/vdr/didweb/store_test.go b/vdr/didweb/store_test.go new file mode 100644 index 0000000000..d6742d69f6 --- /dev/null +++ b/vdr/didweb/store_test.go @@ -0,0 +1,123 @@ +/* + * 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 didweb + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "github.com/google/uuid" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "testing" +) + +var testDID = did.MustParseDID("did:web:example.com") + +func Test_sqlStore_create(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + store := &sqlStore{db: storageEngine.GetSQLDatabase()} + vm1 := testVerificationMethod(t, testDID) + vm2 := testVerificationMethod(t, testDID) + + t.Run("multiple verification methods", func(t *testing.T) { + resetStore(t, store.db) + + err := store.create(testDID, vm1, vm2) + require.NoError(t, err) + + verificationMethods, err := store.get(testDID) + require.NoError(t, err) + require.Len(t, verificationMethods, 2) + require.JSONEq(t, toJSON(vm1), toJSON(verificationMethods[0])) + require.JSONEq(t, toJSON(vm2), toJSON(verificationMethods[1])) + }) + t.Run("single verification method", func(t *testing.T) { + resetStore(t, store.db) + + err := store.create(testDID, vm1) + require.NoError(t, err) + + verificationMethods, err := store.get(testDID) + require.NoError(t, err) + require.Len(t, verificationMethods, 1) + require.JSONEq(t, toJSON(vm1), toJSON(verificationMethods[0])) + }) + t.Run("no verification methods", func(t *testing.T) { + resetStore(t, store.db) + + err := store.create(testDID) + require.NoError(t, err) + + verificationMethods, err := store.get(testDID) + require.NoError(t, err) + require.Len(t, verificationMethods, 0) + }) +} + +func Test_sqlStore_get(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + require.NoError(t, storageEngine.Start()) + + store := &sqlStore{db: storageEngine.GetSQLDatabase()} + + t.Run("DID does not exist", func(t *testing.T) { + resetStore(t, store.db) + + vms, err := store.get(testDID) + require.ErrorIs(t, err, resolver.ErrNotFound) + require.Nil(t, vms, 0) + }) +} + +func resetStore(t *testing.T, db *gorm.DB) { + t.Cleanup(func() { + underlyingDB, err := db.DB() + require.NoError(t, err) + // related tables are emptied due to on-delete-cascade clause + _, err = underlyingDB.Exec("DELETE FROM vdr_didweb") + require.NoError(t, err) + }) +} + +func testVerificationMethod(t *testing.T, owner did.DID) did.VerificationMethod { + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + kid := did.DIDURL{ + DID: owner, + Fragment: uuid.NewString(), + } + result, err := did.NewVerificationMethod(kid, ssi.JsonWebKey2020, owner, privateKey.PublicKey) + require.NoError(t, err) + return *result +} + +func toJSON(v interface{}) string { + result, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(result) +} diff --git a/vdr/integration_test.go b/vdr/integration_test.go index f1f5f661b9..000f2db769 100644 --- a/vdr/integration_test.go +++ b/vdr/integration_test.go @@ -54,7 +54,7 @@ func TestVDRIntegration_Test(t *testing.T) { ctx := setup(t) // Start with a first and fresh document named DocumentA. - docA, _, err := ctx.vdr.Create(ctx.audit, didnuts.DefaultCreationOptions()) + docA, _, err := ctx.vdr.Create(ctx.audit, didnuts.MethodName, didnuts.DefaultCreationOptions()) require.NoError(t, err) assert.NotNil(t, docA) @@ -89,7 +89,7 @@ func TestVDRIntegration_Test(t *testing.T) { "expected updated docA to have a service") // Create a new DID Document we name DocumentB - docB, _, err := ctx.vdr.Create(ctx.audit, didnuts.DefaultCreationOptions()) + docB, _, err := ctx.vdr.Create(ctx.audit, didnuts.MethodName, didnuts.DefaultCreationOptions()) require.NoError(t, err, "unexpected error while creating DocumentB") assert.NotNil(t, docB, "a new document should have been created") @@ -160,7 +160,7 @@ func TestVDRIntegration_ConcurrencyTest(t *testing.T) { ctx := setup(t) // Start with a first and fresh document named DocumentA. - initialDoc, _, err := ctx.vdr.Create(ctx.audit, didnuts.DefaultCreationOptions()) + initialDoc, _, err := ctx.vdr.Create(ctx.audit, didnuts.MethodName, didnuts.DefaultCreationOptions()) require.NoError(t, err) assert.NotNil(t, initialDoc) @@ -226,12 +226,13 @@ func TestVDRIntegration_ReprocessEvents(t *testing.T) { } type testContext struct { - vdr *Module - eventPublisher events.Event - docCreator management.DocCreator - didStore didstore.Store - cryptoInstance *crypto.Crypto - audit context.Context + vdr *Module + eventPublisher events.Event + docCreator management.DocCreator + didStore didstore.Store + cryptoInstance *crypto.Crypto + audit context.Context + storageInstance storage.Engine } func setup(t *testing.T) testContext { @@ -280,7 +281,7 @@ func setup(t *testing.T) testContext { storageEngine.GetProvider("network"), pkiValidator, ) - vdr := NewVDR(cryptoInstance, nutsNetwork, didStore, eventPublisher) + vdr := NewVDR(cryptoInstance, nutsNetwork, didStore, eventPublisher, storageEngine) // Configure require.NoError(t, vdr.Configure(nutsConfig)) @@ -297,11 +298,12 @@ func setup(t *testing.T) testContext { }) return testContext{ - vdr: vdr, - eventPublisher: eventPublisher, - docCreator: vdr.didDocCreator, - didStore: didStore, - cryptoInstance: cryptoInstance, - audit: audit.TestContext(), + vdr: vdr, + eventPublisher: eventPublisher, + docCreator: vdr.creators[didnuts.MethodName], + didStore: didStore, + cryptoInstance: cryptoInstance, + audit: audit.TestContext(), + storageInstance: storageEngine, } } diff --git a/vdr/interface.go b/vdr/interface.go index 114c57eb63..eaa39305dc 100644 --- a/vdr/interface.go +++ b/vdr/interface.go @@ -21,23 +21,22 @@ package vdr import ( "context" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vdr/management" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" ) // VDR defines the public end facing methods for the Verifiable Data Registry. type VDR interface { management.DocumentOwner - management.DocCreator management.DocUpdater + // Create creates a new DID document according to the given DID method and returns it. + Create(ctx context.Context, method string, options management.DIDCreationOptions) (*did.Document, crypto.Key, error) + // ResolveManaged resolves a DID document that is managed by the local node. + ResolveManaged(id did.DID) (*did.Document, error) // Resolver returns the resolver for getting the DID document for a DID. Resolver() resolver.DIDResolver - // ConflictedDocuments returns the DID Document and metadata of all documents with a conflict. ConflictedDocuments() ([]did.Document, []resolver.DocumentMetadata, error) - - // DeriveWebDIDDocument returns the did:web equivalent of the given Nuts DID. If it doesn't exist or is not owned by this node it returns an error. - DeriveWebDIDDocument(ctx context.Context, baseURL url.URL, nutsDID did.DID) (*did.Document, error) } diff --git a/vdr/management/management_mock.go b/vdr/management/management_mock.go index f3d5f10cc2..6ae0dbd41f 100644 --- a/vdr/management/management_mock.go +++ b/vdr/management/management_mock.go @@ -14,9 +14,138 @@ import ( did "github.com/nuts-foundation/go-did/did" crypto "github.com/nuts-foundation/nuts-node/crypto" + resolver "github.com/nuts-foundation/nuts-node/vdr/resolver" gomock "go.uber.org/mock/gomock" ) +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// AddVerificationMethod mocks base method. +func (m *MockManager) AddVerificationMethod(ctx context.Context, id did.DID, keyUsage DIDKeyFlags) (*did.VerificationMethod, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddVerificationMethod", ctx, id, keyUsage) + ret0, _ := ret[0].(*did.VerificationMethod) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddVerificationMethod indicates an expected call of AddVerificationMethod. +func (mr *MockManagerMockRecorder) AddVerificationMethod(ctx, id, keyUsage any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddVerificationMethod", reflect.TypeOf((*MockManager)(nil).AddVerificationMethod), ctx, id, keyUsage) +} + +// Create mocks base method. +func (m *MockManager) Create(ctx context.Context, method string, options DIDCreationOptions) (*did.Document, crypto.Key, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, method, options) + ret0, _ := ret[0].(*did.Document) + ret1, _ := ret[1].(crypto.Key) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Create indicates an expected call of Create. +func (mr *MockManagerMockRecorder) Create(ctx, method, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockManager)(nil).Create), ctx, method, options) +} + +// Deactivate mocks base method. +func (m *MockManager) Deactivate(ctx context.Context, id did.DID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Deactivate", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Deactivate indicates an expected call of Deactivate. +func (mr *MockManagerMockRecorder) Deactivate(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deactivate", reflect.TypeOf((*MockManager)(nil).Deactivate), ctx, id) +} + +// IsOwner mocks base method. +func (m *MockManager) IsOwner(arg0 context.Context, arg1 did.DID) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsOwner", arg0, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsOwner indicates an expected call of IsOwner. +func (mr *MockManagerMockRecorder) IsOwner(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsOwner", reflect.TypeOf((*MockManager)(nil).IsOwner), arg0, arg1) +} + +// ListOwned mocks base method. +func (m *MockManager) ListOwned(ctx context.Context) ([]did.DID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListOwned", ctx) + ret0, _ := ret[0].([]did.DID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListOwned indicates an expected call of ListOwned. +func (mr *MockManagerMockRecorder) ListOwned(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOwned", reflect.TypeOf((*MockManager)(nil).ListOwned), ctx) +} + +// RemoveVerificationMethod mocks base method. +func (m *MockManager) RemoveVerificationMethod(ctx context.Context, id did.DID, keyID did.DIDURL) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveVerificationMethod", ctx, id, keyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveVerificationMethod indicates an expected call of RemoveVerificationMethod. +func (mr *MockManagerMockRecorder) RemoveVerificationMethod(ctx, id, keyID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveVerificationMethod", reflect.TypeOf((*MockManager)(nil).RemoveVerificationMethod), ctx, id, keyID) +} + +// Resolve mocks base method. +func (m *MockManager) Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did.Document, *resolver.DocumentMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", id, metadata) + ret0, _ := ret[0].(*did.Document) + ret1, _ := ret[1].(*resolver.DocumentMetadata) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockManagerMockRecorder) Resolve(id, metadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockManager)(nil).Resolve), id, metadata) +} + // MockDocCreator is a mock of DocCreator interface. type MockDocCreator struct { ctrl *gomock.Controller @@ -41,9 +170,9 @@ func (m *MockDocCreator) EXPECT() *MockDocCreatorMockRecorder { } // Create mocks base method. -func (m *MockDocCreator) Create(ctx context.Context, options DIDCreationOptions) (*did.Document, crypto.Key, error) { +func (m *MockDocCreator) Create(ctx context.Context, method string, options DIDCreationOptions) (*did.Document, crypto.Key, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, options) + ret := m.ctrl.Call(m, "Create", ctx, method, options) ret0, _ := ret[0].(*did.Document) ret1, _ := ret[1].(crypto.Key) ret2, _ := ret[2].(error) @@ -51,9 +180,9 @@ func (m *MockDocCreator) Create(ctx context.Context, options DIDCreationOptions) } // Create indicates an expected call of Create. -func (mr *MockDocCreatorMockRecorder) Create(ctx, options any) *gomock.Call { +func (mr *MockDocCreatorMockRecorder) Create(ctx, method, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockDocCreator)(nil).Create), ctx, options) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockDocCreator)(nil).Create), ctx, method, options) } // MockDocUpdater is a mock of DocUpdater interface. diff --git a/vdr/mock.go b/vdr/mock.go index dbe985c2c8..78c91ade0d 100644 --- a/vdr/mock.go +++ b/vdr/mock.go @@ -10,7 +10,6 @@ package vdr import ( context "context" - url "net/url" reflect "reflect" did "github.com/nuts-foundation/go-did/did" @@ -60,9 +59,9 @@ func (mr *MockVDRMockRecorder) ConflictedDocuments() *gomock.Call { } // Create mocks base method. -func (m *MockVDR) Create(ctx context.Context, options management.DIDCreationOptions) (*did.Document, crypto.Key, error) { +func (m *MockVDR) Create(ctx context.Context, method string, options management.DIDCreationOptions) (*did.Document, crypto.Key, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, options) + ret := m.ctrl.Call(m, "Create", ctx, method, options) ret0, _ := ret[0].(*did.Document) ret1, _ := ret[1].(crypto.Key) ret2, _ := ret[2].(error) @@ -70,24 +69,9 @@ func (m *MockVDR) Create(ctx context.Context, options management.DIDCreationOpti } // Create indicates an expected call of Create. -func (mr *MockVDRMockRecorder) Create(ctx, options any) *gomock.Call { +func (mr *MockVDRMockRecorder) Create(ctx, method, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockVDR)(nil).Create), ctx, options) -} - -// DeriveWebDIDDocument mocks base method. -func (m *MockVDR) DeriveWebDIDDocument(ctx context.Context, baseURL url.URL, nutsDID did.DID) (*did.Document, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeriveWebDIDDocument", ctx, baseURL, nutsDID) - ret0, _ := ret[0].(*did.Document) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeriveWebDIDDocument indicates an expected call of DeriveWebDIDDocument. -func (mr *MockVDRMockRecorder) DeriveWebDIDDocument(ctx, baseURL, nutsDID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeriveWebDIDDocument", reflect.TypeOf((*MockVDR)(nil).DeriveWebDIDDocument), ctx, baseURL, nutsDID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockVDR)(nil).Create), ctx, method, options) } // IsOwner mocks base method. @@ -120,6 +104,21 @@ func (mr *MockVDRMockRecorder) ListOwned(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOwned", reflect.TypeOf((*MockVDR)(nil).ListOwned), ctx) } +// ResolveManaged mocks base method. +func (m *MockVDR) ResolveManaged(id did.DID) (*did.Document, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveManaged", id) + ret0, _ := ret[0].(*did.Document) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveManaged indicates an expected call of ResolveManaged. +func (mr *MockVDRMockRecorder) ResolveManaged(id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveManaged", reflect.TypeOf((*MockVDR)(nil).ResolveManaged), id) +} + // Resolver mocks base method. func (m *MockVDR) Resolver() resolver.DIDResolver { m.ctrl.T.Helper() diff --git a/vdr/vdr.go b/vdr/vdr.go index 212c241dc1..4a3e15d80a 100644 --- a/vdr/vdr.go +++ b/vdr/vdr.go @@ -24,11 +24,11 @@ package vdr import ( - "bytes" "context" "encoding/json" "errors" "fmt" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" @@ -46,7 +46,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/log" "github.com/nuts-foundation/nuts-node/vdr/management" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" ) // ModuleName is the name of the engine @@ -63,50 +62,24 @@ type Module struct { store didnutsStore.Store network network.Transactions networkAmbassador didnuts.Ambassador - didDocCreator management.DocCreator + creators map[string]management.DocCreator + managedResolvers map[string]resolver.DIDResolver + documentOwners map[string]management.DocumentOwner didResolver *resolver.DIDResolverRouter serviceResolver resolver.ServiceResolver - documentOwner management.DocumentOwner keyStore crypto.KeyStore - storageProvider storage.Provider + storageInstance storage.Engine eventManager events.Event } -func (r *Module) DeriveWebDIDDocument(ctx context.Context, baseURL url.URL, nutsDID did.DID) (*did.Document, error) { - nutsDIDDocument, _, err := r.Resolver().Resolve(nutsDID, nil) - if err != nil { - return nil, err - } - isOwner, err := r.IsOwner(ctx, nutsDID) - if err != nil { - return nil, err +// ResolveManaged resolves a DID document that is managed by the local node. +func (r *Module) ResolveManaged(id did.DID) (*did.Document, error) { + managedResolver := r.managedResolvers[id.Method] + if managedResolver == nil { + return nil, fmt.Errorf("unsupported method: %s", id.Method) } - if !isOwner { - log.Logger(). - WithError(err). - WithField(core.LogFieldDID, nutsDID). - Info("Tried to derive did:web document from Nuts DID that is not owned by this node") - return nil, resolver.ErrNotFound - } - - resultDIDDocumentData, _ := json.Marshal(nutsDIDDocument) - // Replace did:nuts DIDs with did:web, but only for owned DIDs - webDID, err := didweb.URLToDID(*baseURL.JoinPath(nutsDID.ID)) - if err != nil { - return nil, fmt.Errorf("unable to derive Web DID from Nuts DID (%s): %w", nutsDID, err) - } - resultDIDDocumentData = bytes.ReplaceAll(resultDIDDocumentData, []byte(nutsDID.String()), []byte(webDID.String())) - var result did.Document - if err = result.UnmarshalJSON(resultDIDDocumentData); err != nil { - return nil, fmt.Errorf("did:web unmarshal error (%s): %w", nutsDID, err) - } - result.AlsoKnownAs = append(result.AlsoKnownAs, nutsDID.URI()) - // did:web support is currently just for supporting third party systems resolving key material, - // so no need to retain services (which often refer to services in other did:nuts documents, complicating things). - result.Service = nil - // did:web does not authenticate DID documents with signatures like did:nuts does, so no need for controllers. - result.Controller = nil - return &result, nil + document, _, err := managedResolver.Resolve(id, nil) + return document, err } func (r *Module) Resolver() resolver.DIDResolver { @@ -115,17 +88,16 @@ func (r *Module) Resolver() resolver.DIDResolver { // NewVDR creates a new Module with provided params func NewVDR(cryptoClient crypto.KeyStore, networkClient network.Transactions, - didStore didnutsStore.Store, eventManager events.Event) *Module { + didStore didnutsStore.Store, eventManager events.Event, storageInstance storage.Engine) *Module { didResolver := &resolver.DIDResolverRouter{} return &Module{ network: networkClient, eventManager: eventManager, - didDocCreator: didnuts.Creator{KeyStore: cryptoClient}, didResolver: didResolver, store: didStore, serviceResolver: resolver.DIDServiceResolver{Resolver: didResolver}, - documentOwner: newCachingDocumentOwner(privateKeyDocumentOwner{keyResolver: cryptoClient}, didResolver), keyStore: cryptoClient, + storageInstance: storageInstance, } } @@ -134,7 +106,7 @@ func (r *Module) Name() string { } // Configure configures the Module engine. -func (r *Module) Configure(_ core.ServerConfig) error { +func (r *Module) Configure(config core.ServerConfig) error { r.networkAmbassador = didnuts.NewAmbassador(r.network, r.store, r.eventManager) // Register DID methods @@ -143,6 +115,24 @@ func (r *Module) Configure(_ core.ServerConfig) error { r.didResolver.Register(didjwk.MethodName, didjwk.NewResolver()) r.didResolver.Register(didkey.MethodName, didkey.NewResolver()) + r.creators = map[string]management.DocCreator{ + didnuts.MethodName: didnuts.Creator{KeyStore: r.keyStore}, + } + r.documentOwners = map[string]management.DocumentOwner{ + didnuts.MethodName: newCachingDocumentOwner(privateKeyDocumentOwner{keyResolver: r.keyStore}, r.didResolver), + } + + // Methods we can produce from the Nuts node + publicURL, err := config.ServerURL() + if err == nil { + didwebManager := didweb.NewManager(*publicURL.JoinPath("iam"), r.keyStore, r.storageInstance.GetSQLDatabase()) + r.creators[didweb.MethodName] = didwebManager + r.documentOwners[didweb.MethodName] = didwebManager + r.managedResolvers = map[string]resolver.DIDResolver{ + didweb.MethodName: didwebManager, + } + } + // Initiate the routines for auto-updating the data. r.networkAmbassador.Configure() return nil @@ -184,11 +174,23 @@ func (r *Module) ConflictedDocuments() ([]did.Document, []resolver.DocumentMetad } func (r *Module) IsOwner(ctx context.Context, id did.DID) (bool, error) { - return r.documentOwner.IsOwner(ctx, id) + owner := r.documentOwners[id.Method] + if owner == nil { + return false, fmt.Errorf("unsupported method: %s", id.Method) + } + return owner.IsOwner(ctx, id) } func (r *Module) ListOwned(ctx context.Context) ([]did.DID, error) { - return r.documentOwner.ListOwned(ctx) + var results []did.DID + for _, owner := range r.documentOwners { + owned, err := owner.ListOwned(ctx) + if err != nil { + return nil, err + } + results = append(results, owned...) + } + return results, nil } // newOwnConflictedDocIterator accepts two counters and returns a new DocIterator that counts the total number of @@ -265,7 +267,7 @@ func (r *Module) Diagnostics() []core.DiagnosticResult { } // Create generates a new DID Document -func (r *Module) Create(ctx context.Context, options management.DIDCreationOptions) (*did.Document, crypto.Key, error) { +func (r *Module) Create(ctx context.Context, method string, options management.DIDCreationOptions) (*did.Document, crypto.Key, error) { log.Logger().Debug("Creating new DID Document.") // for all controllers given in the options, we need to capture the metadata so the new transaction can reference to it @@ -283,26 +285,32 @@ func (r *Module) Create(ctx context.Context, options management.DIDCreationOptio } } - doc, key, err := r.didDocCreator.Create(ctx, options) - if err != nil { - return nil, nil, fmt.Errorf("could not create DID document: %w", err) + creator := r.creators[method] + if creator == nil { + return nil, nil, fmt.Errorf("unsupported method: %s", method) } - - payload, err := json.Marshal(doc) + doc, key, err := creator.Create(ctx, options) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("could not create DID document (method %s): %w", method, err) } - // extract the transaction refs from the controller metadata - refs := make([]hash.SHA256Hash, 0) - for _, meta := range controllerMetadata { - refs = append(refs, meta.SourceTransactions...) - } + if method == didnuts.MethodName { + payload, err := json.Marshal(doc) + if err != nil { + return nil, nil, err + } - tx := network.TransactionTemplate(didnuts.DIDDocumentType, payload, key).WithAttachKey().WithAdditionalPrevs(refs) - _, err = r.network.CreateTransaction(ctx, tx) - if err != nil { - return nil, nil, fmt.Errorf("could not store DID document in network: %w", err) + // extract the transaction refs from the controller metadata + refs := make([]hash.SHA256Hash, 0) + for _, meta := range controllerMetadata { + refs = append(refs, meta.SourceTransactions...) + } + + tx := network.TransactionTemplate(didnuts.DIDDocumentType, payload, key).WithAttachKey().WithAdditionalPrevs(refs) + _, err = r.network.CreateTransaction(ctx, tx) + if err != nil { + return nil, nil, fmt.Errorf("could not store DID document in network: %w", err) + } } log.Logger(). diff --git a/vdr/vdr_test.go b/vdr/vdr_test.go index 62a312f717..6506a30def 100644 --- a/vdr/vdr_test.go +++ b/vdr/vdr_test.go @@ -29,13 +29,13 @@ import ( "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/didnuts" "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore" "github.com/nuts-foundation/nuts-node/vdr/management" "github.com/nuts-foundation/nuts-node/vdr/resolver" "io" "net/http" - "net/url" "strings" "testing" @@ -76,10 +76,12 @@ func newVDRTestCtx(t *testing.T) vdrTestCtx { store: mockStore, network: mockNetwork, networkAmbassador: mockAmbassador, - didDocCreator: didnuts.Creator{KeyStore: mockKeyStore}, - didResolver: resolverRouter, - documentOwner: mockDocumentOwner, - keyStore: mockKeyStore, + creators: map[string]management.DocCreator{ + didnuts.MethodName: &didnuts.Creator{KeyStore: mockKeyStore}, + }, + didResolver: resolverRouter, + documentOwners: map[string]management.DocumentOwner{didnuts.MethodName: mockDocumentOwner}, + keyStore: mockKeyStore, } resolverRouter.Register(didnuts.MethodName, &didnuts.Resolver{Store: mockStore}) return vdrTestCtx{ @@ -207,7 +209,7 @@ func TestVDR_Create(t *testing.T) { test.mockKeyStore.EXPECT().New(test.ctx, gomock.Any()).Return(key, nil) test.mockNetwork.EXPECT().CreateTransaction(test.ctx, network.TransactionTemplate(expectedPayloadType, expectedPayload, key).WithAttachKey().WithAdditionalPrevs([]hash.SHA256Hash{})) - didDoc, key, err := test.vdr.Create(test.ctx, didnuts.DefaultCreationOptions()) + didDoc, key, err := test.vdr.Create(test.ctx, didnuts.MethodName, didnuts.DefaultCreationOptions()) assert.NoError(t, err) assert.NotNil(t, didDoc) @@ -230,7 +232,7 @@ func TestVDR_Create(t *testing.T) { test.mockStore.EXPECT().Resolve(controllerID, gomock.Any()).Return(&controllerDocument, &resolver.DocumentMetadata{SourceTransactions: refs}, nil) test.mockNetwork.EXPECT().CreateTransaction(test.ctx, network.TransactionTemplate(expectedPayloadType, expectedPayload, key).WithAttachKey().WithAdditionalPrevs(refs)) - didDoc, key, err := test.vdr.Create(test.ctx, creationOptions) + didDoc, key, err := test.vdr.Create(test.ctx, didnuts.MethodName, creationOptions) assert.NoError(t, err) assert.NotNil(t, didDoc) @@ -246,7 +248,7 @@ func TestVDR_Create(t *testing.T) { } test.mockStore.EXPECT().Resolve(controllerID, gomock.Any()).Return(nil, nil, resolver.ErrNotFound) - _, _, err := test.vdr.Create(test.ctx, creationOptions) + _, _, err := test.vdr.Create(test.ctx, didnuts.MethodName, creationOptions) assert.EqualError(t, err, "could not create DID document: could not resolve a controller: unable to find the DID document") }) @@ -255,9 +257,9 @@ func TestVDR_Create(t *testing.T) { test := newVDRTestCtx(t) test.mockKeyStore.EXPECT().New(gomock.Any(), gomock.Any()).Return(nil, errors.New("b00m!")) - _, _, err := test.vdr.Create(test.ctx, didnuts.DefaultCreationOptions()) + _, _, err := test.vdr.Create(test.ctx, didnuts.MethodName, didnuts.DefaultCreationOptions()) - assert.EqualError(t, err, "could not create DID document: b00m!") + assert.EqualError(t, err, "could not create DID document (method nuts): b00m!") }) t.Run("error - transaction failed", func(t *testing.T) { @@ -266,14 +268,14 @@ func TestVDR_Create(t *testing.T) { test.mockKeyStore.EXPECT().New(gomock.Any(), gomock.Any()).Return(key, nil) test.mockNetwork.EXPECT().CreateTransaction(gomock.Any(), gomock.Any()).Return(nil, errors.New("b00m!")) - _, _, err := test.vdr.Create(test.ctx, didnuts.DefaultCreationOptions()) + _, _, err := test.vdr.Create(test.ctx, didnuts.MethodName, didnuts.DefaultCreationOptions()) assert.EqualError(t, err, "could not store DID document in network: b00m!") }) } func TestNewVDR(t *testing.T) { - vdr := NewVDR(nil, nil, nil, nil) + vdr := NewVDR(nil, nil, nil, nil, nil) assert.IsType(t, &Module{}, vdr) } @@ -315,7 +317,7 @@ func TestVDR_Start(t *testing.T) { func TestVDR_ConflictingDocuments(t *testing.T) { t.Run("diagnostics", func(t *testing.T) { t.Run("ok - no conflicts/no documents", func(t *testing.T) { - vdr := NewVDR(nil, nil, nil, nil) + vdr := NewVDR(nil, nil, nil, nil, nil) vdr.store = didstore.NewTestStore(t) results := vdr.Diagnostics() @@ -325,7 +327,7 @@ func TestVDR_ConflictingDocuments(t *testing.T) { }) t.Run("ok - 1 conflict", func(t *testing.T) { - vdr := NewVDR(nil, nil, nil, nil) + vdr := NewVDR(nil, nil, nil, nil, nil) vdr.store = didstore.NewTestStore(t) didDocument := did.Document{ID: TestDIDA} _ = vdr.store.Add(didDocument, didstore.TestTransaction(didDocument)) @@ -342,8 +344,9 @@ func TestVDR_ConflictingDocuments(t *testing.T) { keyID := did.DIDURL{DID: TestDIDA} keyID.Fragment = "1" _, _ = client.New(audit.TestContext(), crypto.StringNamingFunc(keyID.String())) - vdr := NewVDR(client, nil, didstore.NewTestStore(t), nil) - vdr.didResolver.Register(didnuts.MethodName, didnuts.Resolver{Store: vdr.store}) + vdr := NewVDR(client, nil, didstore.NewTestStore(t), nil, storage.NewTestStorageEngine(t)) + _ = vdr.Configure(*core.NewServerConfig()) + //vdr.didResolver.Register(didnuts.MethodName, didnuts.Resolver{Store: vdr.store}) didDocument := did.Document{ID: TestDIDA} didDocument.AddCapabilityInvocation(&did.VerificationMethod{ID: keyID}) @@ -362,14 +365,14 @@ func TestVDR_ConflictingDocuments(t *testing.T) { keyVendor := crypto.NewTestKey("did:nuts:vendor#keyVendor-1") test.mockKeyStore.EXPECT().New(test.ctx, gomock.Any()).Return(keyVendor, nil) test.mockNetwork.EXPECT().CreateTransaction(test.ctx, gomock.Any()).AnyTimes() - didDocVendor, keyVendor, err := test.vdr.Create(test.ctx, didnuts.DefaultCreationOptions()) + didDocVendor, keyVendor, err := test.vdr.Create(test.ctx, didnuts.MethodName, didnuts.DefaultCreationOptions()) require.NoError(t, err) // organization keyOrg := crypto.NewTestKey("did:nuts:org#keyOrg-1") test.mockKeyStore.EXPECT().New(test.ctx, gomock.Any()).Return(keyOrg, nil).Times(2) test.mockStore.EXPECT().Resolve(didDocVendor.ID, nil).Return(didDocVendor, &resolver.DocumentMetadata{}, nil) - didDocOrg, keyOrg, err := test.vdr.Create(test.ctx, management.DIDCreationOptions{ + didDocOrg, keyOrg, err := test.vdr.Create(test.ctx, didnuts.MethodName, management.DIDCreationOptions{ Controllers: []did.DID{didDocVendor.ID}, KeyFlags: management.AssertionMethodUsage | management.KeyAgreementUsage, SelfControl: false, @@ -379,7 +382,8 @@ func TestVDR_ConflictingDocuments(t *testing.T) { client := crypto.NewMemoryCryptoInstance() _, _ = client.New(audit.TestContext(), crypto.StringNamingFunc(keyVendor.KID())) _, _ = client.New(audit.TestContext(), crypto.StringNamingFunc(keyOrg.KID())) - vdr := NewVDR(client, nil, didstore.NewTestStore(t), nil) + vdr := NewVDR(client, nil, didstore.NewTestStore(t), nil, storage.NewTestStorageEngine(t)) + _ = vdr.Configure(*core.NewServerConfig()) vdr.didResolver.Register(didnuts.MethodName, didnuts.Resolver{Store: vdr.store}) _ = vdr.store.Add(*didDocVendor, didstore.TestTransaction(*didDocVendor)) @@ -394,7 +398,7 @@ func TestVDR_ConflictingDocuments(t *testing.T) { }) t.Run("list", func(t *testing.T) { t.Run("ok - no conflicts", func(t *testing.T) { - vdr := NewVDR(nil, nil, nil, nil) + vdr := NewVDR(nil, nil, nil, nil, nil) vdr.store = didstore.NewTestStore(t) docs, meta, err := vdr.ConflictedDocuments() @@ -404,7 +408,7 @@ func TestVDR_ConflictingDocuments(t *testing.T) { }) t.Run("ok - 1 conflict", func(t *testing.T) { - vdr := NewVDR(nil, nil, nil, nil) + vdr := NewVDR(nil, nil, nil, nil, nil) vdr.store = didstore.NewTestStore(t) didDocument := did.Document{ID: TestDIDA} _ = vdr.store.Add(didDocument, didstore.TestTransaction(didDocument)) @@ -504,8 +508,9 @@ func TestVDR_IsOwner(t *testing.T) { ctrl := gomock.NewController(t) owner := management.NewMockDocumentOwner(ctrl) owner.EXPECT().IsOwner(gomock.Any(), id).Return(true, nil) + owners := map[string]management.DocumentOwner{didnuts.MethodName: owner} - result, err := (&Module{documentOwner: owner}).IsOwner(context.Background(), id) + result, err := (&Module{documentOwners: owners}).IsOwner(context.Background(), id) assert.NoError(t, err) assert.True(t, result) @@ -513,6 +518,7 @@ func TestVDR_IsOwner(t *testing.T) { } func TestVDR_Configure(t *testing.T) { + storageInstance := storage.NewTestStorageEngine(t) t.Run("it can resolve using did:web", func(t *testing.T) { http.DefaultTransport = roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ @@ -522,8 +528,8 @@ func TestVDR_Configure(t *testing.T) { }, nil }) - instance := NewVDR(nil, nil, nil, nil) - err := instance.Configure(core.ServerConfig{}) + instance := NewVDR(nil, nil, nil, nil, storageInstance) + err := instance.Configure(core.ServerConfig{URL: "https://nuts.nl"}) require.NoError(t, err) doc, md, err := instance.Resolver().Resolve(did.MustParseDID("did:web:example.com"), nil) @@ -542,7 +548,7 @@ func TestVDR_Configure(t *testing.T) { inputDID, err := did.ParseDID(inputDIDString) require.NoError(t, err) - instance := NewVDR(nil, nil, nil, nil) + instance := NewVDR(nil, nil, nil, nil, storageInstance) err = instance.Configure(core.ServerConfig{}) require.NoError(t, err) @@ -556,7 +562,7 @@ func TestVDR_Configure(t *testing.T) { assert.Equal(t, "P-256", doc.VerificationMethod[0].PublicKeyJwk["crv"]) }) t.Run("it can resolve using did:key", func(t *testing.T) { - instance := NewVDR(nil, nil, nil, nil) + instance := NewVDR(nil, nil, nil, nil, storageInstance) err := instance.Configure(core.ServerConfig{}) require.NoError(t, err) @@ -574,100 +580,3 @@ func (fn roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return fn(r) } -func TestVDR_DeriveWebDIDDocument(t *testing.T) { - nutsDID := did.MustParseDID("did:nuts:123") - webDID := did.MustParseDID("did:web:example.com:iam:123") - baseURL, _ := url.Parse("https://example.com/iam") - nutsDIDDoc := did.Document{ - ID: nutsDID, - Controller: []did.DID{nutsDID}, - Service: []did.Service{ - { - ID: ssi.MustParseURI(nutsDID.String() + "#service1"), - Type: "eOverdracht-sender", - ServiceEndpoint: ssi.MustParseURI(nutsDID.String() + "#service2"), - }, - }, - VerificationMethod: []*did.VerificationMethod{ - { - ID: did.MustParseDIDURL(nutsDID.String() + "#key1"), - Controller: nutsDID, - }, - }, - CapabilityInvocation: []did.VerificationRelationship{ - { - VerificationMethod: &did.VerificationMethod{ - ID: did.MustParseDIDURL(nutsDID.String() + "#key1"), - Controller: nutsDID, - }, - }, - }, - } - expectedWebDIDDoc := did.Document{ - ID: webDID, - AlsoKnownAs: []ssi.URI{ - nutsDID.URI(), - }, - VerificationMethod: []*did.VerificationMethod{ - { - ID: did.MustParseDIDURL(webDID.String() + "#key1"), - Controller: webDID, - }, - }, - CapabilityInvocation: []did.VerificationRelationship{ - { - VerificationMethod: &did.VerificationMethod{ - ID: did.MustParseDIDURL(webDID.String() + "#key1"), - Controller: webDID, - }, - }, - }, - } - // remarshal expectedWebDIDDoc to make sure in-memory format is the same as the one returned by the API - data, _ := json.Marshal(expectedWebDIDDoc) - _ = expectedWebDIDDoc.UnmarshalJSON(data) - - t.Run("ok", func(t *testing.T) { - ctx := newVDRTestCtx(t) - - ctx.mockStore.EXPECT().Resolve(nutsDID, nil).Return(&nutsDIDDoc, nil, nil) - ctx.mockOwner.EXPECT().IsOwner(gomock.Any(), nutsDID).Return(true, nil) - - actual, err := ctx.vdr.DeriveWebDIDDocument(nil, *baseURL, nutsDID) - - require.NoError(t, err) - assert.Equal(t, expectedWebDIDDoc, *actual) - }) - t.Run("not owned by the node", func(t *testing.T) { - ctx := newVDRTestCtx(t) - - ctx.mockStore.EXPECT().Resolve(nutsDID, nil).Return(&nutsDIDDoc, nil, nil) - ctx.mockOwner.EXPECT().IsOwner(gomock.Any(), nutsDID).Return(false, nil) - - actual, err := ctx.vdr.DeriveWebDIDDocument(nil, *baseURL, nutsDID) - - assert.ErrorIs(t, err, resolver.ErrNotFound) - assert.Nil(t, actual) - }) - t.Run("resolver error", func(t *testing.T) { - ctx := newVDRTestCtx(t) - - ctx.mockStore.EXPECT().Resolve(nutsDID, nil).Return(nil, nil, resolver.ErrNotFound) - - actual, err := ctx.vdr.DeriveWebDIDDocument(nil, *baseURL, nutsDID) - - assert.ErrorIs(t, err, resolver.ErrNotFound) - assert.Nil(t, actual) - }) - t.Run("ownership check error", func(t *testing.T) { - ctx := newVDRTestCtx(t) - - ctx.mockStore.EXPECT().Resolve(nutsDID, nil).Return(&nutsDIDDoc, nil, nil) - ctx.mockOwner.EXPECT().IsOwner(gomock.Any(), nutsDID).Return(false, errors.New("failed")) - - actual, err := ctx.vdr.DeriveWebDIDDocument(nil, *baseURL, nutsDID) - - assert.EqualError(t, err, "failed") - assert.Nil(t, actual) - }) -}