diff --git a/.codeclimate.yml b/.codeclimate.yml
index e736d509f7..1d4ffdd5d1 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -13,7 +13,7 @@ exclude_patterns:
- "test/*"
- "**/mock.go"
- "**/generated.go"
- - "mock/*"
+ - "**/mock/*"
plugins:
gofmt:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 9240993c30..7a439f9d0c 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -20,4 +20,4 @@ func Test_rootCmd(t *testing.T) {
assert.NoError(t, CreateCommand().Execute())
assert.False(t, routesCalled, "engine.Routes was called")
})
-}
\ No newline at end of file
+}
diff --git a/core/config.go b/core/config.go
index 1c4d6aa27e..d8e3c8f5ce 100644
--- a/core/config.go
+++ b/core/config.go
@@ -131,7 +131,6 @@ func (ngc NutsGlobalConfig) Mode() string {
return ngc.v.GetString(modeFlag)
}
-
// GetEngineMode configures an engine mode if not already configured. If the application is started in 'cli' mode,
// its engines are configured to run in 'client' mode. This function returns the proper mode for the engine in and should be used as follows:
// engineConfig.Mode = GetEngineMode(engineConfig.Mode)
diff --git a/core/constants.go b/core/constants.go
index 9aad08839c..8e6ed0c9a3 100644
--- a/core/constants.go
+++ b/core/constants.go
@@ -26,4 +26,4 @@ const NutsOID = "1.3.6.1.4.1.54851"
const NutsConsentClassesOID = NutsOID + ".1"
// NutsVendorOID is the sub-OID used for vendor identifiers
-const NutsVendorOID = NutsOID + ".4"
\ No newline at end of file
+const NutsVendorOID = NutsOID + ".4"
diff --git a/crypto/api/v1/api.go b/crypto/api/v1/api.go
new file mode 100644
index 0000000000..0ccc2714ee
--- /dev/null
+++ b/crypto/api/v1/api.go
@@ -0,0 +1,108 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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
+
+import (
+ "errors"
+ "mime"
+ "net/http"
+
+ "github.com/nuts-foundation/nuts-node/crypto"
+ "github.com/nuts-foundation/nuts-node/crypto/log"
+ "github.com/nuts-foundation/nuts-node/crypto/util"
+
+ "github.com/labstack/echo/v4"
+ "github.com/lestrrat-go/jwx/jwk"
+ "github.com/nuts-foundation/nuts-node/crypto/storage"
+)
+
+// Wrapper implements the generated interface from oapi-codegen
+type Wrapper struct {
+ C crypto.KeyStore
+}
+
+func (signRequest SignJwtRequest) validate() error {
+ if len(signRequest.Kid) == 0 {
+ return errors.New("missing kid")
+ }
+
+ if len(signRequest.Claims) == 0 {
+ return errors.New("missing claims")
+ }
+
+ return nil
+}
+
+// SignJwt handles api calls for signing a Jwt
+func (w *Wrapper) SignJwt(ctx echo.Context) error {
+ var signRequest = &SignJwtRequest{}
+ err := ctx.Bind(signRequest)
+ if err != nil {
+ log.Logger().Error(err.Error())
+ return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+ }
+
+ if err := signRequest.validate(); err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, err)
+ }
+
+ sig, err := w.C.SignJWT(signRequest.Claims, signRequest.Kid)
+
+ if err != nil {
+ log.Logger().Error(err.Error())
+ return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+ }
+
+ return ctx.String(http.StatusOK, sig)
+}
+
+// PublicKey returns a public key for the given kid. The urn represents a legal entity. The api returns the public key either in PEM or JWK format.
+// It uses the accept header to determine this. Default is PEM (text/plain), only when application/json is requested will it return JWK.
+func (w *Wrapper) PublicKey(ctx echo.Context, kid string) error {
+ acceptHeader := ctx.Request().Header.Get("Accept")
+
+ pubKey, err := w.C.GetPublicKey(kid)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) {
+ return ctx.NoContent(404)
+ }
+ log.Logger().Error(err.Error())
+ return err
+ }
+
+ // starts with so we can ignore any +
+ if ct, _, _ := mime.ParseMediaType(acceptHeader); ct == "application/json" {
+ jwk, err := jwk.New(pubKey)
+ if err != nil {
+ log.Logger().Error(err.Error())
+ return err
+ }
+
+ return ctx.JSON(http.StatusOK, jwk)
+ }
+
+ // backwards compatible PEM format is the default
+ pub, err := util.PublicKeyToPem(pubKey)
+ if err != nil {
+ log.Logger().Error(err.Error())
+ return err
+ }
+
+ return ctx.String(http.StatusOK, pub)
+}
diff --git a/crypto/api/v1/api_test.go b/crypto/api/v1/api_test.go
new file mode 100644
index 0000000000..c8fe0ab8e9
--- /dev/null
+++ b/crypto/api/v1/api_test.go
@@ -0,0 +1,198 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ mock2 "github.com/nuts-foundation/nuts-node/crypto/mock"
+ "github.com/nuts-foundation/nuts-node/crypto/storage"
+ "github.com/nuts-foundation/nuts-node/crypto/test"
+ "github.com/nuts-foundation/nuts-node/mock"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWrapper_SignJwt(t *testing.T) {
+ t.Run("Missing claims returns 400", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ jsonRequest := SignJwtRequest{
+ Kid: "kid",
+ }
+ jsonData, _ := json.Marshal(jsonRequest)
+
+ ctx.echo.EXPECT().Bind(gomock.Any()).Do(func(f interface{}) {
+ _ = json.Unmarshal(jsonData, f)
+ })
+
+ err := ctx.client.SignJwt(ctx.echo)
+
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "code=400, message=missing claims")
+ })
+
+ t.Run("Missing kid returns 400", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ jsonRequest := SignJwtRequest{
+ Claims: map[string]interface{}{"iss": "nuts"},
+ }
+ jsonData, _ := json.Marshal(jsonRequest)
+
+ ctx.echo.EXPECT().Bind(gomock.Any()).Do(func(f interface{}) {
+ _ = json.Unmarshal(jsonData, f)
+ })
+
+ err := ctx.client.SignJwt(ctx.echo)
+
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "code=400, message=missing kid")
+ })
+
+ t.Run("Sign error returns 400", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ jsonRequest := SignJwtRequest{
+ Kid: "unknown",
+ Claims: map[string]interface{}{"iss": "nuts"},
+ }
+ jsonData, _ := json.Marshal(jsonRequest)
+
+ ctx.echo.EXPECT().Bind(gomock.Any()).Do(func(f interface{}) {
+ _ = json.Unmarshal(jsonData, f)
+ })
+ ctx.keyStore.EXPECT().SignJWT(gomock.Any(), "unknown").Return("", errors.New("b00m!"))
+
+ err := ctx.client.SignJwt(ctx.echo)
+
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "code=400, message=b00m!")
+ })
+
+ t.Run("All OK returns 200", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ jsonRequest := SignJwtRequest{
+ Kid: "kid",
+ Claims: map[string]interface{}{"iss": "nuts"},
+ }
+
+ jsonData, _ := json.Marshal(jsonRequest)
+
+ ctx.echo.EXPECT().Bind(gomock.Any()).Do(func(f interface{}) {
+ _ = json.Unmarshal(jsonData, f)
+ })
+ ctx.keyStore.EXPECT().SignJWT(gomock.Any(), "kid").Return("token", nil)
+ ctx.echo.EXPECT().String(http.StatusOK, "token")
+
+ err := ctx.client.SignJwt(ctx.echo)
+
+ assert.Nil(t, err)
+ })
+
+ t.Run("Missing body gives 400", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ ctx.echo.EXPECT().Bind(gomock.Any()).Return(errors.New("missing body in request"))
+
+ err := ctx.client.SignJwt(ctx.echo)
+
+ assert.NotNil(t, err)
+ assert.Contains(t, err.Error(), "code=400, message=missing body in request")
+ })
+}
+
+func TestWrapper_PublicKey(t *testing.T) {
+ t.Run("PublicKey API call returns 200", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ key := test.GenerateECKey()
+
+ ctx.echo.EXPECT().Request().Return(&http.Request{})
+ ctx.keyStore.EXPECT().GetPublicKey("kid").Return(key.Public(), nil)
+ ctx.echo.EXPECT().String(http.StatusOK, gomock.Any())
+
+ _ = ctx.client.PublicKey(ctx.echo, "kid")
+ })
+
+ t.Run("PublicKey API call returns JWK", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ key := test.GenerateECKey()
+
+ ctx.echo.EXPECT().Request().Return(&http.Request{Header: http.Header{"Accept": []string{"application/json"}}})
+ ctx.keyStore.EXPECT().GetPublicKey("kid").Return(key.Public(), nil)
+ ctx.echo.EXPECT().JSON(http.StatusOK, gomock.Any())
+
+ _ = ctx.client.PublicKey(ctx.echo, "kid")
+ })
+
+ t.Run("PublicKey API call returns 404 for unknown", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ ctx.echo.EXPECT().Request().Return(&http.Request{})
+ ctx.keyStore.EXPECT().GetPublicKey("kid").Return(nil, storage.ErrNotFound)
+ ctx.echo.EXPECT().NoContent(http.StatusNotFound)
+
+ _ = ctx.client.PublicKey(ctx.echo, "kid")
+ })
+
+ t.Run("PublicKey API call returns 500 for other error", func(t *testing.T) {
+ ctx := newMockContext(t)
+ defer ctx.ctrl.Finish()
+
+ ctx.echo.EXPECT().Request().Return(&http.Request{Header: http.Header{"Accept": []string{"application/json"}}})
+ ctx.keyStore.EXPECT().GetPublicKey("kid").Return(nil, errors.New("b00m!"))
+
+ err := ctx.client.PublicKey(ctx.echo, "kid")
+ assert.Error(t, err)
+ })
+}
+
+type mockContext struct {
+ ctrl *gomock.Controller
+ echo *mock.MockContext
+ keyStore *mock2.MockKeyStore
+ client *Wrapper
+}
+
+func newMockContext(t *testing.T) mockContext {
+ ctrl := gomock.NewController(t)
+ keyStore := mock2.NewMockKeyStore(ctrl)
+ client := &Wrapper{C: keyStore}
+
+ return mockContext{
+ ctrl: ctrl,
+ echo: mock.NewMockContext(ctrl),
+ keyStore: keyStore,
+ client: client,
+ }
+}
diff --git a/crypto/api/v1/client.go b/crypto/api/v1/client.go
new file mode 100644
index 0000000000..f711838c91
--- /dev/null
+++ b/crypto/api/v1/client.go
@@ -0,0 +1,89 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2020. 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
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/lestrrat-go/jwx/jwk"
+)
+
+// ErrNotImplemented indicates that this client API call is not implemented.
+var ErrNotImplemented = errors.New("operation not implemented")
+
+// HTTPClient holds the server address and other basic settings for the http client
+type HTTPClient struct {
+ ServerAddress string
+ Timeout time.Duration
+}
+
+func (hb HTTPClient) clientWithRequestEditor(fn RequestEditorFn) ClientInterface {
+ url := hb.ServerAddress
+ if !strings.Contains(url, "http") {
+ url = fmt.Sprintf("http://%v", hb.ServerAddress)
+ }
+
+ response, err := NewClientWithResponses(url, WithRequestEditorFn(fn))
+ if err != nil {
+ panic(err)
+ }
+ return response
+}
+
+func (hb HTTPClient) client() ClientInterface {
+ return hb.clientWithRequestEditor(nil)
+}
+
+// GetPublicKey returns a PublicKey from the server given a kid
+func (hb HTTPClient) GetPublicKey(kid string) (jwk.Key, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), hb.Timeout)
+ defer cancel()
+ httpClient := hb.clientWithRequestEditor(func(ctx context.Context, req *http.Request) error {
+ req.Header.Add("Accept", "application/json")
+ return nil
+ })
+ response, err := httpClient.PublicKey(ctx, kid)
+ if err != nil {
+ return nil, err
+ }
+ if err := testResponseCode(http.StatusOK, response); err != nil {
+ return nil, err
+ }
+ jwkSet, err := jwk.Parse(response.Body)
+ if err != nil {
+ return nil, err
+ }
+ return jwkSet.Keys[0], nil
+}
+
+func testResponseCode(expectedStatusCode int, response *http.Response) error {
+ if response.StatusCode != expectedStatusCode {
+ responseData, _ := ioutil.ReadAll(response.Body)
+ return fmt.Errorf("server returned HTTP %d (expected: %d), response: %s",
+ response.StatusCode, expectedStatusCode, string(responseData))
+ }
+ return nil
+}
diff --git a/crypto/api/v1/client_test.go b/crypto/api/v1/client_test.go
new file mode 100644
index 0000000000..9d03ace1ee
--- /dev/null
+++ b/crypto/api/v1/client_test.go
@@ -0,0 +1,85 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2020. 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
+
+import (
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type handler struct {
+ statusCode int
+ responseData []byte
+}
+
+func (h handler) ServeHTTP(writer http.ResponseWriter, req *http.Request) {
+ writer.WriteHeader(h.statusCode)
+ writer.Write(h.responseData)
+}
+
+var genericError = []byte("failed")
+
+var jwkAsString = `
+{
+ "kty" : "RSA",
+ "n" : "pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK0lndimbMMVBdPv_hSpm8T8EtBDxrUdi1OHZfMhUixGaut-3nQ4GG9nM249oxhCtxqqNvEXrmQRGqczyLxuh-fKn9Fg--hS9UpazHpfVAFnB5aCfXoNhPuI8oByyFKMKaOVgHNqP5NBEqabiLftZD3W_lsFCPGuzr4Vp0YS7zS2hDYScC2oOMu4rGU1LcMZf39p3153Cq7bS2Xh6Y-vw5pwzFYZdjQxDn8x8BG3fJ6j8TGLXQsbKH1218_HcUJRvMwdpbUQG5nvA2GXVqLqdwp054Lzk9_B_f1lVrmOKuHjTNHq48w",
+ "e" : "AQAB"
+}`
+var jwkAsBytes = []byte(jwkAsString)
+
+func TestHttpClient_GetPublicKey(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ s := httptest.NewServer(handler{statusCode: http.StatusOK, responseData: jwkAsBytes})
+ c := HTTPClient{ServerAddress: s.URL, Timeout: time.Second}
+ res, err := c.GetPublicKey("kid")
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.NotNil(t, res)
+ })
+ t.Run("error - server returned non-JWK", func(t *testing.T) {
+ csrBytes, _ := ioutil.ReadFile("../test/broken.pem")
+ s := httptest.NewServer(handler{statusCode: http.StatusOK, responseData: csrBytes})
+ c := HTTPClient{ServerAddress: s.URL, Timeout: time.Second}
+ res, err := c.GetPublicKey("kid")
+ assert.Contains(t, err.Error(), "failed to unmarshal JWK:")
+ assert.Nil(t, res)
+ })
+ t.Run("error - response not HTTP OK", func(t *testing.T) {
+ s := httptest.NewServer(handler{statusCode: http.StatusInternalServerError, responseData: genericError})
+ c := HTTPClient{ServerAddress: s.URL, Timeout: time.Second}
+ res, err := c.GetPublicKey("kid")
+ assert.EqualError(t, err, "server returned HTTP 500 (expected: 200), response: failed")
+ assert.Nil(t, res)
+ })
+ t.Run("error - server not running", func(t *testing.T) {
+ s := httptest.NewServer(handler{statusCode: http.StatusOK})
+ s.Close()
+ c := HTTPClient{ServerAddress: s.URL, Timeout: time.Second}
+ res, err := c.GetPublicKey("kid")
+ assert.Contains(t, err.Error(), "connection refused")
+ assert.Nil(t, res)
+ })
+}
diff --git a/crypto/api/v1/generated.go b/crypto/api/v1/generated.go
new file mode 100644
index 0000000000..e39292278a
--- /dev/null
+++ b/crypto/api/v1/generated.go
@@ -0,0 +1,465 @@
+// Package v1 provides primitives to interact the openapi HTTP API.
+//
+// Code generated by github.com/deepmap/oapi-codegen DO NOT EDIT.
+package v1
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/deepmap/oapi-codegen/pkg/runtime"
+ "github.com/labstack/echo/v4"
+)
+
+// JWK defines model for JWK.
+type JWK map[string]interface{}
+
+// PublicKey defines model for PublicKey.
+type PublicKey string
+
+// SignJwtRequest defines model for SignJwtRequest.
+type SignJwtRequest struct {
+ Claims map[string]interface{} `json:"claims"`
+ Kid string `json:"kid"`
+}
+
+// SignJwtJSONBody defines parameters for SignJwt.
+type SignJwtJSONBody SignJwtRequest
+
+// SignJwtRequestBody defines body for SignJwt for application/json ContentType.
+type SignJwtJSONRequestBody SignJwtJSONBody
+
+// RequestEditorFn is the function signature for the RequestEditor callback function
+type RequestEditorFn func(ctx context.Context, req *http.Request) error
+
+// Doer performs HTTP requests.
+//
+// The standard http.Client implements this interface.
+type HttpRequestDoer interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+// Client which conforms to the OpenAPI3 specification for this service.
+type Client struct {
+ // The endpoint of the server conforming to this interface, with scheme,
+ // https://api.deepmap.com for example. This can contain a path relative
+ // to the server, such as https://api.deepmap.com/dev-test, and all the
+ // paths in the swagger spec will be appended to the server.
+ Server string
+
+ // Doer for performing requests, typically a *http.Client with any
+ // customized settings, such as certificate chains.
+ Client HttpRequestDoer
+
+ // A callback for modifying requests which are generated before sending over
+ // the network.
+ RequestEditor RequestEditorFn
+}
+
+// ClientOption allows setting custom parameters during construction
+type ClientOption func(*Client) error
+
+// Creates a new Client, with reasonable defaults
+func NewClient(server string, opts ...ClientOption) (*Client, error) {
+ // create a client with sane default values
+ client := Client{
+ Server: server,
+ }
+ // mutate client and add all optional params
+ for _, o := range opts {
+ if err := o(&client); err != nil {
+ return nil, err
+ }
+ }
+ // ensure the server URL always has a trailing slash
+ if !strings.HasSuffix(client.Server, "/") {
+ client.Server += "/"
+ }
+ // create httpClient, if not already present
+ if client.Client == nil {
+ client.Client = http.DefaultClient
+ }
+ return &client, nil
+}
+
+// WithHTTPClient allows overriding the default Doer, which is
+// automatically created using http.Client. This is useful for tests.
+func WithHTTPClient(doer HttpRequestDoer) ClientOption {
+ return func(c *Client) error {
+ c.Client = doer
+ return nil
+ }
+}
+
+// WithRequestEditorFn allows setting up a callback function, which will be
+// called right before sending the request. This can be used to mutate the request.
+func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
+ return func(c *Client) error {
+ c.RequestEditor = fn
+ return nil
+ }
+}
+
+// The interface specification for the client above.
+type ClientInterface interface {
+ // PublicKey request
+ PublicKey(ctx context.Context, kid string) (*http.Response, error)
+
+ // SignJwt request with any body
+ SignJwtWithBody(ctx context.Context, contentType string, body io.Reader) (*http.Response, error)
+
+ SignJwt(ctx context.Context, body SignJwtJSONRequestBody) (*http.Response, error)
+}
+
+func (c *Client) PublicKey(ctx context.Context, kid string) (*http.Response, error) {
+ req, err := NewPublicKeyRequest(c.Server, kid)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if c.RequestEditor != nil {
+ err = c.RequestEditor(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) SignJwtWithBody(ctx context.Context, contentType string, body io.Reader) (*http.Response, error) {
+ req, err := NewSignJwtRequestWithBody(c.Server, contentType, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if c.RequestEditor != nil {
+ err = c.RequestEditor(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) SignJwt(ctx context.Context, body SignJwtJSONRequestBody) (*http.Response, error) {
+ req, err := NewSignJwtRequest(c.Server, body)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if c.RequestEditor != nil {
+ err = c.RequestEditor(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return c.Client.Do(req)
+}
+
+// NewPublicKeyRequest generates requests for PublicKey
+func NewPublicKeyRequest(server string, kid string) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParam("simple", false, "kid", kid)
+ if err != nil {
+ return nil, err
+ }
+
+ queryUrl, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ basePath := fmt.Sprintf("/internal/crypto/v1/public_key/%s", pathParam0)
+ if basePath[0] == '/' {
+ basePath = basePath[1:]
+ }
+
+ queryUrl, err = queryUrl.Parse(basePath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("GET", queryUrl.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
+// NewSignJwtRequest calls the generic SignJwt builder with application/json body
+func NewSignJwtRequest(server string, body SignJwtJSONRequestBody) (*http.Request, error) {
+ var bodyReader io.Reader
+ buf, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ bodyReader = bytes.NewReader(buf)
+ return NewSignJwtRequestWithBody(server, "application/json", bodyReader)
+}
+
+// NewSignJwtRequestWithBody generates requests for SignJwt with any type of body
+func NewSignJwtRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
+ var err error
+
+ queryUrl, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ basePath := fmt.Sprintf("/internal/crypto/v1/sign_jwt")
+ if basePath[0] == '/' {
+ basePath = basePath[1:]
+ }
+
+ queryUrl, err = queryUrl.Parse(basePath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", queryUrl.String(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Content-Type", contentType)
+ return req, nil
+}
+
+// ClientWithResponses builds on ClientInterface to offer response payloads
+type ClientWithResponses struct {
+ ClientInterface
+}
+
+// NewClientWithResponses creates a new ClientWithResponses, which wraps
+// Client with return type handling
+func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
+ client, err := NewClient(server, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return &ClientWithResponses{client}, nil
+}
+
+// WithBaseURL overrides the baseURL.
+func WithBaseURL(baseURL string) ClientOption {
+ return func(c *Client) error {
+ newBaseURL, err := url.Parse(baseURL)
+ if err != nil {
+ return err
+ }
+ c.Server = newBaseURL.String()
+ return nil
+ }
+}
+
+// ClientWithResponsesInterface is the interface specification for the client with responses above.
+type ClientWithResponsesInterface interface {
+ // PublicKey request
+ PublicKeyWithResponse(ctx context.Context, kid string) (*PublicKeyResponse, error)
+
+ // SignJwt request with any body
+ SignJwtWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader) (*SignJwtResponse, error)
+
+ SignJwtWithResponse(ctx context.Context, body SignJwtJSONRequestBody) (*SignJwtResponse, error)
+}
+
+type PublicKeyResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON200 *JWK
+}
+
+// Status returns HTTPResponse.Status
+func (r PublicKeyResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r PublicKeyResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+type SignJwtResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+}
+
+// Status returns HTTPResponse.Status
+func (r SignJwtResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r SignJwtResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+// PublicKeyWithResponse request returning *PublicKeyResponse
+func (c *ClientWithResponses) PublicKeyWithResponse(ctx context.Context, kid string) (*PublicKeyResponse, error) {
+ rsp, err := c.PublicKey(ctx, kid)
+ if err != nil {
+ return nil, err
+ }
+ return ParsePublicKeyResponse(rsp)
+}
+
+// SignJwtWithBodyWithResponse request with arbitrary body returning *SignJwtResponse
+func (c *ClientWithResponses) SignJwtWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader) (*SignJwtResponse, error) {
+ rsp, err := c.SignJwtWithBody(ctx, contentType, body)
+ if err != nil {
+ return nil, err
+ }
+ return ParseSignJwtResponse(rsp)
+}
+
+func (c *ClientWithResponses) SignJwtWithResponse(ctx context.Context, body SignJwtJSONRequestBody) (*SignJwtResponse, error) {
+ rsp, err := c.SignJwt(ctx, body)
+ if err != nil {
+ return nil, err
+ }
+ return ParseSignJwtResponse(rsp)
+}
+
+// ParsePublicKeyResponse parses an HTTP response from a PublicKeyWithResponse call
+func ParsePublicKeyResponse(rsp *http.Response) (*PublicKeyResponse, error) {
+ bodyBytes, err := ioutil.ReadAll(rsp.Body)
+ defer rsp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &PublicKeyResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
+ var dest JWK
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON200 = &dest
+
+ case rsp.StatusCode == 200:
+ // Content-type (text/plain) unsupported
+
+ }
+
+ return response, nil
+}
+
+// ParseSignJwtResponse parses an HTTP response from a SignJwtWithResponse call
+func ParseSignJwtResponse(rsp *http.Response) (*SignJwtResponse, error) {
+ bodyBytes, err := ioutil.ReadAll(rsp.Body)
+ defer rsp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &SignJwtResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ }
+
+ return response, nil
+}
+
+// ServerInterface represents all server handlers.
+type ServerInterface interface {
+ // get the public key for a given kid. It returns the key in PEM or JWK format. This depends on the accept header used (text/plain vs application/json)
+ // (GET /internal/crypto/v1/public_key/{kid})
+ PublicKey(ctx echo.Context, kid string) error
+ // sign a JWT payload with the private key of the given kid
+ // (POST /internal/crypto/v1/sign_jwt)
+ SignJwt(ctx echo.Context) error
+}
+
+// ServerInterfaceWrapper converts echo contexts to parameters.
+type ServerInterfaceWrapper struct {
+ Handler ServerInterface
+}
+
+// PublicKey converts echo context to params.
+func (w *ServerInterfaceWrapper) PublicKey(ctx echo.Context) error {
+ var err error
+ // ------------- Path parameter "kid" -------------
+ var kid string
+
+ err = runtime.BindStyledParameter("simple", false, "kid", ctx.Param("kid"), &kid)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter kid: %s", err))
+ }
+
+ // Invoke the callback with all the unmarshalled arguments
+ err = w.Handler.PublicKey(ctx, kid)
+ return err
+}
+
+// SignJwt converts echo context to params.
+func (w *ServerInterfaceWrapper) SignJwt(ctx echo.Context) error {
+ var err error
+
+ // Invoke the callback with all the unmarshalled arguments
+ err = w.Handler.SignJwt(ctx)
+ return err
+}
+
+// This is a simple interface which specifies echo.Route addition functions which
+// are present on both echo.Echo and echo.Group, since we want to allow using
+// either of them for path registration
+type EchoRouter interface {
+ CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+}
+
+// RegisterHandlers adds each server route to the EchoRouter.
+func RegisterHandlers(router EchoRouter, si ServerInterface) {
+ RegisterHandlersWithBaseURL(router, si, "")
+}
+
+// Registers handlers, and prepends BaseURL to the paths, so that the paths
+// can be served under a prefix.
+func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) {
+
+ wrapper := ServerInterfaceWrapper{
+ Handler: si,
+ }
+
+ router.GET(baseURL+"/internal/crypto/v1/public_key/:kid", wrapper.PublicKey)
+ router.POST(baseURL+"/internal/crypto/v1/sign_jwt", wrapper.SignJwt)
+
+}
diff --git a/crypto/api/v1/generated_test.go b/crypto/api/v1/generated_test.go
new file mode 100644
index 0000000000..b353a55257
--- /dev/null
+++ b/crypto/api/v1/generated_test.go
@@ -0,0 +1,99 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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
+
+import (
+ "errors"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/labstack/echo/v4"
+ "github.com/magiconair/properties/assert"
+ "github.com/nuts-foundation/nuts-node/mock"
+)
+
+type testServerInterface struct {
+ err error
+}
+
+func (t *testServerInterface) GenerateKeyPair(ctx echo.Context) error {
+ return t.err
+}
+
+func (t *testServerInterface) PublicKey(ctx echo.Context, urn string) error {
+ return t.err
+}
+
+func (t *testServerInterface) SignJwt(ctx echo.Context) error {
+ return t.err
+}
+
+var siws = []*ServerInterfaceWrapper{
+ serverInterfaceWrapper(nil), serverInterfaceWrapper(errors.New("Server error")),
+}
+
+func TestServerInterfaceWrapper_PublicKey(t *testing.T) {
+ for _, siw := range siws {
+ t.Run("PublicKey call returns expected error", func(t *testing.T) {
+ req := httptest.NewRequest(echo.GET, "/", nil)
+ rec := httptest.NewRecorder()
+ c := echo.New().NewContext(req, rec)
+ c.SetParamNames("kid")
+ c.SetParamValues("1")
+
+ err := siw.PublicKey(c)
+ tsi := siw.Handler.(*testServerInterface)
+ assert.Equal(t, tsi.err, err)
+ })
+ }
+}
+
+func TestServerInterfaceWrapper_SignJwt(t *testing.T) {
+ for _, siw := range siws {
+ t.Run("SignJWT call returns expected error", func(t *testing.T) {
+ req := httptest.NewRequest(echo.GET, "/", nil)
+ rec := httptest.NewRecorder()
+ c := echo.New().NewContext(req, rec)
+
+ err := siw.SignJwt(c)
+ tsi := siw.Handler.(*testServerInterface)
+ assert.Equal(t, tsi.err, err)
+ })
+ }
+}
+
+func TestRegisterHandlers(t *testing.T) {
+ t.Run("Registers routes for crypto module", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ echo := mock.NewMockEchoRouter(ctrl)
+
+ echo.EXPECT().POST("/internal/crypto/v1/sign_jwt", gomock.Any())
+ echo.EXPECT().GET("/internal/crypto/v1/public_key/:kid", gomock.Any())
+
+ RegisterHandlers(echo, &testServerInterface{})
+ })
+}
+
+func serverInterfaceWrapper(err error) *ServerInterfaceWrapper {
+ return &ServerInterfaceWrapper{
+ Handler: &testServerInterface{err: err},
+ }
+}
diff --git a/crypto/crypto.go b/crypto/crypto.go
new file mode 100644
index 0000000000..580843174c
--- /dev/null
+++ b/crypto/crypto.go
@@ -0,0 +1,168 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 crypto
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "errors"
+ "io"
+ "sync"
+
+ "github.com/lestrrat-go/jwx/jwk"
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/nuts-foundation/nuts-node/crypto/storage"
+)
+
+// Config holds the values for the crypto engine
+type Config struct {
+ Storage string
+ Fspath string
+}
+
+func (cc Config) getFSPath() string {
+ if cc.Fspath == "" {
+ return DefaultCryptoConfig().Fspath
+ }
+
+ return cc.Fspath
+}
+
+// DefaultCryptoConfig returns a Config with sane defaults
+func DefaultCryptoConfig() Config {
+ return Config{
+ Storage: "fs",
+ Fspath: "./",
+ }
+}
+
+// Crypto holds references to storage and needed config
+type Crypto struct {
+ Storage storage.Storage
+ Config Config
+ configOnce sync.Once
+ configDone bool
+}
+
+type opaquePrivateKey struct {
+ publicKey crypto.PublicKey
+ signFn func(io.Reader, []byte, crypto.SignerOpts) ([]byte, error)
+}
+
+// Public returns the public key
+func (k opaquePrivateKey) Public() crypto.PublicKey {
+ return k.publicKey
+}
+
+// Sign signs some data with the signer
+func (k opaquePrivateKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) {
+ return k.signFn(rand, digest, opts)
+}
+
+// Shutdown stops the certificate monitors
+func (client *Crypto) Shutdown() error {
+ return nil
+}
+
+// GetPrivateKey returns the specified private key. It can be used for signing, but cannot be exported.
+func (client *Crypto) GetPrivateKey(kid string) (crypto.Signer, error) {
+ priv, err := client.Storage.GetPrivateKey(kid)
+ if err != nil {
+ return nil, err
+ }
+ return opaquePrivateKey{publicKey: priv.Public(), signFn: priv.Sign}, nil
+}
+
+var instance *Crypto
+
+var oneBackend sync.Once
+
+// Instance returns the same instance of Crypto every time
+func Instance() *Crypto {
+ if instance != nil {
+ return instance
+ }
+ oneBackend.Do(func() {
+ instance = &Crypto{
+ Config: DefaultCryptoConfig(),
+ }
+ })
+ return instance
+}
+
+// Configure loads the given configurations in the engine. Any wrong combination will return an error
+func (client *Crypto) Configure() error {
+ var err error
+ client.configOnce.Do(func() {
+ if core.NutsConfig().Mode() != core.ServerEngineMode {
+ return
+ }
+ if err = client.doConfigure(); err == nil {
+ client.configDone = true
+ }
+ })
+ return err
+}
+
+func (client *Crypto) doConfigure() error {
+ if client.Config.Storage != "fs" && client.Config.Storage != "" {
+ return errors.New("only fs backend available for now")
+ }
+ var err error
+ if client.Storage, err = storage.NewFileSystemBackend(client.Config.getFSPath()); err != nil {
+ return err
+ }
+ return nil
+}
+
+// New generates a new key pair. If a key is overwritten is handled by the storage implementation.
+// it's considered bad practise to reuse a kid for different keys.
+func (client *Crypto) New(namingFunc KidNamingFunc) (crypto.PublicKey, string, error) {
+ keyPair, err := generateECKeyPair()
+ if err != nil {
+ return nil, "", err
+ }
+
+ kid := namingFunc(keyPair.Public())
+ if err = client.Storage.SavePrivateKey(kid, keyPair); err != nil {
+ return nil, "", err
+ }
+
+ pkey, err := jwk.PublicKeyOf(keyPair)
+ if err != nil {
+ return nil, "", err
+ }
+ return pkey, kid, nil
+}
+
+func generateECKeyPair() (*ecdsa.PrivateKey, error) {
+ return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+}
+
+// PrivateKeyExists checks storage for an entry for the given legal entity and returns true if it exists
+func (client *Crypto) PrivateKeyExists(kid string) bool {
+ return client.Storage.PrivateKeyExists(kid)
+}
+
+// GetPublicKey loads the key from storage
+func (client *Crypto) GetPublicKey(kid string) (crypto.PublicKey, error) {
+ return client.Storage.GetPublicKey(kid)
+}
diff --git a/crypto/crypto_test.go b/crypto/crypto_test.go
new file mode 100644
index 0000000000..58d9508131
--- /dev/null
+++ b/crypto/crypto_test.go
@@ -0,0 +1,206 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 crypto
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rand"
+ "crypto/rsa"
+ "errors"
+ "reflect"
+ "testing"
+
+ "github.com/nuts-foundation/nuts-node/test/io"
+
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/spf13/cobra"
+
+ "github.com/nuts-foundation/nuts-node/crypto/storage"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCryptoBackend(t *testing.T) {
+ t.Run("Instance always returns same instance", func(t *testing.T) {
+ client := Instance()
+ client2 := Instance()
+
+ if client != client2 {
+ t.Error("Expected instances to be the same")
+ }
+ })
+}
+
+func TestCrypto_PublicKey(t *testing.T) {
+ client := createCrypto(t)
+
+ kid := "kid"
+ client.New(StringNamingFunc(kid))
+
+ t.Run("Public key is returned from storage", func(t *testing.T) {
+ pub, err := client.GetPublicKey(kid)
+
+ assert.Nil(t, err)
+ assert.NotEmpty(t, pub)
+ })
+
+ t.Run("Public key for unknown entity returns error", func(t *testing.T) {
+ _, err := client.GetPublicKey("unknown")
+
+ if assert.Error(t, err) {
+ assert.True(t, errors.Is(err, storage.ErrNotFound))
+ }
+ })
+}
+
+func TestCrypto_GetPrivateKey(t *testing.T) {
+ client := createCrypto(t)
+
+ t.Run("private key not found", func(t *testing.T) {
+ pk, err := client.GetPrivateKey("unknown")
+ assert.Nil(t, pk)
+ assert.Error(t, err)
+ })
+ t.Run("get private key, assert non-exportable", func(t *testing.T) {
+ kid := "kid"
+ client.New(StringNamingFunc(kid))
+
+ pk, err := client.GetPrivateKey(kid)
+ if !assert.NoError(t, err) {
+ return
+ }
+ if !assert.NotNil(t, pk) {
+ return
+ }
+ // Assert that we don't accidentally return the actual RSA/ECDSA key, because they should stay in the storage
+ // and be non-exportable.
+ _, ok := pk.(*rsa.PrivateKey)
+ assert.False(t, ok)
+ _, ok = pk.(*ecdsa.PrivateKey)
+ assert.False(t, ok)
+ })
+
+ t.Run("get private key, assert parts", func(t *testing.T) {
+ kid := "kid2"
+ client.New(StringNamingFunc(kid))
+
+ pk, _ := client.GetPrivateKey(kid)
+ if !assert.NotNil(t, pk) {
+ return
+ }
+
+ ok := pk.(opaquePrivateKey)
+ assert.NotNil(t, ok.Public())
+
+ _, err := ok.Sign(rand.Reader, []byte("hi"), crypto.SHA256)
+ assert.NoError(t, err)
+ })
+}
+
+func TestCrypto_KeyExistsFor(t *testing.T) {
+ client := createCrypto(t)
+
+ kid := "kid"
+ client.New(StringNamingFunc(kid))
+
+ t.Run("returns true for existing key", func(t *testing.T) {
+ assert.True(t, client.PrivateKeyExists(kid))
+ })
+
+ t.Run("returns false for non-existing key", func(t *testing.T) {
+ assert.False(t, client.PrivateKeyExists("unknown"))
+ })
+}
+
+func TestCrypto_New(t *testing.T) {
+ client := createCrypto(t)
+
+ t.Run("ok", func(t *testing.T) {
+ kid := "kid"
+ publicKey, returnKid, err := client.New(StringNamingFunc(kid))
+ assert.NoError(t, err)
+ assert.NotNil(t, publicKey)
+ assert.Equal(t, kid, returnKid)
+ })
+}
+
+func TestCrypto_doConfigure(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ e := createCrypto(t)
+ err := e.doConfigure()
+ assert.NoError(t, err)
+ })
+ t.Run("ok - default = fs backend", func(t *testing.T) {
+ client := createCrypto(t)
+ err := client.doConfigure()
+ if !assert.NoError(t, err) {
+ return
+ }
+ storageType := reflect.TypeOf(client.Storage).String()
+ assert.Equal(t, "*storage.fileSystemBackend", storageType)
+ })
+ t.Run("error - unknown backend", func(t *testing.T) {
+ client := createCrypto(t)
+ client.Config.Storage = "unknown"
+ err := client.doConfigure()
+ assert.EqualErrorf(t, err, "only fs backend available for now", "expected error")
+ })
+}
+
+func TestCrypto_Configure(t *testing.T) {
+ createCrypto(t)
+
+ t.Run("ok - configOnce", func(t *testing.T) {
+ e := createCrypto(t)
+ assert.False(t, e.configDone)
+ err := e.Configure()
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.True(t, e.configDone)
+ err = e.Configure()
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.True(t, e.configDone)
+ })
+}
+
+func TestCryptoConfig_getFsPath(t *testing.T) {
+ t.Run("no path configured returns defaultPath", func(t *testing.T) {
+ c := Config{
+ Fspath: "",
+ }
+ assert.Equal(t, "./", c.getFSPath())
+ })
+}
+
+func createCrypto(t *testing.T) *Crypto {
+ if err := core.NutsConfig().Load(&cobra.Command{}); err != nil {
+ panic(err)
+ }
+ dir := io.TestDirectory(t)
+ backend, _ := storage.NewFileSystemBackend(dir)
+ crypto := Crypto{
+ Storage: backend,
+ Config: TestCryptoConfig(dir),
+ }
+
+ return &crypto
+}
diff --git a/crypto/engine/engine.go b/crypto/engine/engine.go
new file mode 100644
index 0000000000..e7c54010c7
--- /dev/null
+++ b/crypto/engine/engine.go
@@ -0,0 +1,145 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 engine
+
+import (
+ "crypto"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/nuts-foundation/nuts-node/core"
+ crypto2 "github.com/nuts-foundation/nuts-node/crypto"
+ api "github.com/nuts-foundation/nuts-node/crypto/api/v1"
+ "github.com/nuts-foundation/nuts-node/crypto/util"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// ConfigStorage is used as --storage config flag
+const ConfigStorage string = "storage"
+
+// ConfigFSPath is used as --fspath config flagclient.getStoragePath()
+const ConfigFSPath string = "fspath"
+
+// NewCryptoEngine the engine configuration for nuts-go.
+func NewCryptoEngine() *core.Engine {
+ cb := crypto2.Instance()
+
+ return &core.Engine{
+ Cmd: cmd(),
+ Config: &cb.Config,
+ ConfigKey: "crypto",
+ Configure: cb.Configure,
+ FlagSet: flagSet(),
+ Name: "Crypto",
+ Routes: func(router core.EchoRouter) {
+ api.RegisterHandlers(router, &api.Wrapper{C: cb})
+ },
+ Shutdown: cb.Shutdown,
+ }
+}
+
+// FlagSet returns the configuration flags for crypto
+func flagSet() *pflag.FlagSet {
+ flags := pflag.NewFlagSet("crypto", pflag.ContinueOnError)
+
+ defs := crypto2.DefaultCryptoConfig()
+ flags.String(ConfigStorage, defs.Storage, fmt.Sprintf("Storage to use, 'fs' for file system, default: %s", defs.Storage))
+ flags.String(ConfigFSPath, defs.Fspath, fmt.Sprintf("When file system is used as storage, this configures the path where key material and the truststore are persisted, default: %v", defs.Fspath))
+
+ return flags
+}
+
+// Cmd gives the sub-commands made available through crypto:
+// * generateKeyPair: generate a new keyPair for a given legalEntity
+// * publicKey: retrieve the keyPair for a given legalEntity
+func cmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "crypto",
+ Short: "crypto commands",
+ }
+ cmd.AddCommand(&cobra.Command{
+ Use: "publicKey [kid]",
+ Short: "views the publicKey for a given kid",
+ Long: "views the publicKey for a given kid. It'll output a JWK encoded public key and a PEM encoded public key.",
+
+ Args: func(cmd *cobra.Command, args []string) error {
+ if len(args) < 1 {
+ return errors.New("requires a kid argument")
+ }
+
+ return nil
+ },
+ Run: func(cmd *cobra.Command, args []string) {
+ cc := newCryptoClient(cmd)
+ kid := args[0]
+
+ jwkKey, err := cc.GetPublicKey(kid)
+ if err != nil {
+ cmd.Printf("Error printing publicKey: %v", err)
+ return
+ }
+
+ // printout in JWK
+ if err != nil {
+ cmd.Printf("Error printing publicKey: %v", err)
+ return
+ }
+ asJSON, err := json.MarshalIndent(jwkKey, "", " ")
+ if err != nil {
+ cmd.Printf("Error printing publicKey: %v\n", err)
+ return
+ }
+ cmd.Println("Public key in JWK:")
+ cmd.Println(string(asJSON))
+ cmd.Println("")
+
+ // printout in PEM
+ var target interface{}
+ err = jwkKey.Raw(&target)
+ if err != nil {
+ cmd.Printf("Error printing publicKey: %v\n", err)
+ return
+ }
+
+ publicKeyAsPEM, err := util.PublicKeyToPem(target.(crypto.PublicKey))
+ if err != nil {
+ cmd.Printf("Error printing publicKey: %v\n", err)
+ return
+ }
+ cmd.Println("Public key in PEM:")
+ cmd.Println(publicKeyAsPEM)
+ },
+ })
+
+ return cmd
+}
+
+// newCryptoClient creates a remote client
+func newCryptoClient(cmd *cobra.Command) api.HTTPClient {
+ cfg := core.NutsConfig()
+ cfg.Load(cmd)
+
+ return api.HTTPClient{
+ ServerAddress: cfg.ServerAddress(),
+ Timeout: 10 * time.Second,
+ }
+}
diff --git a/crypto/engine/engine_test.go b/crypto/engine/engine_test.go
new file mode 100644
index 0000000000..f27db3e97f
--- /dev/null
+++ b/crypto/engine/engine_test.go
@@ -0,0 +1,175 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 engine
+
+import (
+ "bytes"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/nuts-foundation/nuts-node/crypto"
+ "github.com/nuts-foundation/nuts-node/mock"
+ "github.com/nuts-foundation/nuts-node/test/io"
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewCryptoEngine(t *testing.T) {
+ t.Run("New returns an engine with Cmd and Routes", func(t *testing.T) {
+ client := NewCryptoEngine()
+
+ if client.Cmd == nil {
+ t.Errorf("Expected Engine to have Cmd")
+ }
+
+ if client.Routes == nil {
+ t.Errorf("Expected Engine to have Routes")
+ }
+ })
+}
+
+func TestNewCryptoEngine_Routes(t *testing.T) {
+ t.Run("Registers the available routes", func(t *testing.T) {
+ ce := NewCryptoEngine()
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ echo := mock.NewMockEchoRouter(ctrl)
+
+ echo.EXPECT().POST("/internal/crypto/v1/sign_jwt", gomock.Any())
+ echo.EXPECT().GET("/internal/crypto/v1/public_key/:kid", gomock.Any())
+
+ ce.Routes(echo)
+ })
+}
+
+type handler struct {
+ statusCode int
+ responseData []byte
+}
+
+func (h handler) ServeHTTP(writer http.ResponseWriter, req *http.Request) {
+ writer.WriteHeader(h.statusCode)
+ writer.Write(h.responseData)
+}
+var jwkAsString = `
+{
+ "kty" : "RSA",
+ "n" : "pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK0lndimbMMVBdPv_hSpm8T8EtBDxrUdi1OHZfMhUixGaut-3nQ4GG9nM249oxhCtxqqNvEXrmQRGqczyLxuh-fKn9Fg--hS9UpazHpfVAFnB5aCfXoNhPuI8oByyFKMKaOVgHNqP5NBEqabiLftZD3W_lsFCPGuzr4Vp0YS7zS2hDYScC2oOMu4rGU1LcMZf39p3153Cq7bS2Xh6Y-vw5pwzFYZdjQxDn8x8BG3fJ6j8TGLXQsbKH1218_HcUJRvMwdpbUQG5nvA2GXVqLqdwp054Lzk9_B_f1lVrmOKuHjTNHq48w",
+ "e" : "AQAB"
+}`
+var jwkAsBytes = []byte(jwkAsString)
+
+func TestNewCryptoEngine_Cmd(t *testing.T) {
+ core.NutsConfig().Load(&cobra.Command{})
+
+ createCmd := func(t *testing.T) (*cobra.Command, *crypto.Crypto) {
+ testDirectory := io.TestDirectory(t)
+ instance := crypto.NewTestCryptoInstance(testDirectory)
+ return NewCryptoEngine().Cmd, instance
+ }
+
+ t.Run("publicKey", func(t *testing.T) {
+ t.Run("error - too few arguments", func(t *testing.T) {
+ cmd, _ := createCmd(t)
+ cmd.SetArgs([]string{"publicKey"})
+ cmd.SetOut(ioutil.Discard)
+ err := cmd.Execute()
+
+ if assert.Error(t, err) {
+ assert.Equal(t, "requires a kid argument", err.Error())
+ }
+ })
+
+ t.Run("error - public key does not exist", func(t *testing.T) {
+ cmd, _ := createCmd(t)
+ buf := new(bytes.Buffer)
+ cmd.SetArgs([]string{"publicKey", "unknown"})
+ cmd.SetOut(buf)
+ err := cmd.Execute()
+ if !assert.NoError(t, err) {
+ return
+ }
+ })
+
+ t.Run("ok - write to stdout", func(t *testing.T) {
+ cmd, _ := createCmd(t)
+ s := httptest.NewServer(handler{statusCode: http.StatusOK, responseData: jwkAsBytes})
+ os.Setenv("NUTS_ADDRESS", s.URL)
+ core.NutsConfig().Load(cmd)
+ defer s.Close()
+
+ buf := new(bytes.Buffer)
+ cmd.SetArgs([]string{"publicKey", "kid"})
+ cmd.SetOut(buf)
+ err := cmd.Execute()
+
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Contains(t, buf.String(), "Public key in JWK")
+ assert.Contains(t, buf.String(), "Public key in PEM")
+ })
+ })
+}
+
+func TestNewCryptoEngine_FlagSet(t *testing.T) {
+ t.Run("Cobra help should list flags", func(t *testing.T) {
+ e := NewCryptoEngine()
+ cmd := newRootCommand()
+ cmd.Flags().AddFlagSet(e.FlagSet)
+ cmd.SetArgs([]string{"--help"})
+
+ buf := new(bytes.Buffer)
+ cmd.SetOut(buf)
+
+ _, err := cmd.ExecuteC()
+
+ if err != nil {
+ t.Errorf("Expected no error, got %s", err.Error())
+ }
+
+ result := buf.String()
+
+ if !strings.Contains(result, "--storage") {
+ t.Errorf("Expected --storage to be command line flag")
+ }
+
+ if !strings.Contains(result, "--fspath") {
+ t.Errorf("Expected --fspath to be command line flag")
+ }
+
+ })
+}
+
+func newRootCommand() *cobra.Command {
+ testRootCommand := &cobra.Command{
+ Use: "root",
+ Run: func(cmd *cobra.Command, args []string) {
+
+ },
+ }
+
+ return testRootCommand
+}
diff --git a/crypto/interface.go b/crypto/interface.go
new file mode 100644
index 0000000000..993d152ba6
--- /dev/null
+++ b/crypto/interface.go
@@ -0,0 +1,44 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 crypto
+
+import (
+ "crypto"
+)
+
+// KidNamingFunc is a function passed to New() which generates the kid for the pub/priv key
+type KidNamingFunc func(key crypto.PublicKey) string
+
+// KeyStore defines the functions than can be called by a Cmd, Direct or via rest call.
+type KeyStore interface {
+ // New generates a keypair and returns the public key.
+ // the KidNamingFunc will provide the kid. priv/pub keys are appended with a postfix and stored
+ New(namingFunc KidNamingFunc) (crypto.PublicKey, string, error)
+ // GetPrivateKey returns the specified private key (for e.g. signing) in non-exportable form.
+ // If a key is missing, a Storage.ErrNotFound is returned
+ GetPrivateKey(kid string) (crypto.Signer, error)
+ // GetPublicKey returns the PublicKey
+ // If a key is missing, a Storage.ErrNotFound is returned
+ GetPublicKey(kid string) (crypto.PublicKey, error)
+ // SignJWT creates a signed JWT using the given key and map of claims (private key must be present).
+ SignJWT(claims map[string]interface{}, kid string) (string, error)
+ // PrivateKeyExists returns if the specified private key exists.
+ // If an error occurs, false is also returned
+ PrivateKeyExists(kid string) bool
+}
diff --git a/crypto/jwx.go b/crypto/jwx.go
new file mode 100644
index 0000000000..9d0e790cf1
--- /dev/null
+++ b/crypto/jwx.go
@@ -0,0 +1,149 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2020. 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 crypto
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "errors"
+
+ "github.com/lestrrat-go/jwx/jwa"
+ "github.com/lestrrat-go/jwx/jwk"
+ "github.com/lestrrat-go/jwx/jws"
+ "github.com/lestrrat-go/jwx/jwt"
+)
+
+// ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported
+var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported")
+
+// SignJWT creates a signed JWT given a legalEntity and map of claims
+func (client *Crypto) SignJWT(claims map[string]interface{}, kid string) (token string, err error) {
+ privateKey, err := client.Storage.GetPrivateKey(kid)
+
+ if err != nil {
+ return "", err
+ }
+ key, err := jwkKey(privateKey)
+ if err != nil {
+ return "", err
+ }
+
+ if err = key.Set(jwk.KeyIDKey, kid); err != nil {
+ return "", err
+ }
+
+ token, err = SignJWT(key, claims, nil)
+ return
+}
+
+func jwkKey(signer crypto.Signer) (key jwk.Key, err error) {
+ key, err = jwk.New(signer)
+ if err != nil {
+ return nil, err
+ }
+
+ switch signer.(type) {
+ case *rsa.PrivateKey:
+ key.Set(jwk.AlgorithmKey, jwa.PS256)
+ case *ecdsa.PrivateKey:
+ ecKey := signer.(*ecdsa.PrivateKey)
+ var alg jwa.SignatureAlgorithm
+ alg, err = ecAlg(ecKey)
+ key.Set(jwk.AlgorithmKey, alg)
+ default:
+ err = errors.New("unsupported signing private key")
+ }
+ return
+}
+
+// SignJWT signs claims with the signer and returns the compacted token. The headers param can be used to add additional headers
+func SignJWT(key jwk.Key, claims map[string]interface{}, headers map[string]interface{}) (token string, err error) {
+ var sig []byte
+ t := jwt.New()
+
+ for k, v := range claims {
+ t.Set(k, v)
+ }
+ hdr := convertHeaders(headers)
+
+ sig, err = jwt.Sign(t, jwa.SignatureAlgorithm(key.Algorithm()), key, jws.WithHeaders(hdr))
+ token = string(sig)
+
+ return
+}
+
+// JWTKidAlg parses a JWT, does not validate it and returns the 'kid' and 'alg' headers
+func JWTKidAlg(tokenString string) (string, jwa.SignatureAlgorithm, error) {
+ j, err := jws.ParseString(tokenString)
+ if err != nil {
+ return "", "", err
+ }
+
+ if len(j.Signatures()) != 1 {
+ return "", "", errors.New("incorrect number of signatures in JWT")
+ }
+
+ sig := j.Signatures()[0]
+ hdrs := sig.ProtectedHeaders()
+ return hdrs.KeyID(), hdrs.Algorithm(), nil
+}
+
+// PublicKeyFunc defines a function that resolves a public key based on a kid
+type PublicKeyFunc func(kid string) (crypto.PublicKey, error)
+
+// ParseJWT parses a token, validates and verifies it.
+func ParseJWT(tokenString string, f PublicKeyFunc) (jwt.Token, error) {
+ kid, alg, err := JWTKidAlg(tokenString)
+ if err != nil {
+ return nil, err
+ }
+
+ key, err := f(kid)
+ if err != nil {
+ return nil, err
+ }
+
+ return jwt.ParseString(tokenString, jwt.WithVerify(alg, key), jwt.WithValidate(true))
+}
+
+func convertHeaders(headers map[string]interface{}) (hdr jws.Headers) {
+ hdr = jws.NewHeaders()
+
+ if headers != nil {
+ for k, v := range headers {
+ hdr.Set(k, v)
+ }
+ }
+ return
+}
+
+func ecAlg(key *ecdsa.PrivateKey) (alg jwa.SignatureAlgorithm, err error) {
+ switch key.Params().BitSize {
+ case 256:
+ alg = jwa.ES256
+ case 384:
+ alg = jwa.ES384
+ case 521:
+ alg = jwa.ES512
+ default:
+ err = ErrUnsupportedSigningKey
+ }
+ return
+}
diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go
new file mode 100644
index 0000000000..b511801ed3
--- /dev/null
+++ b/crypto/jwx_test.go
@@ -0,0 +1,151 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2020. 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 crypto
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "fmt"
+ "testing"
+
+ "github.com/lestrrat-go/jwx/jwa"
+ "github.com/lestrrat-go/jwx/jwk"
+ "github.com/lestrrat-go/jwx/jws"
+ "github.com/nuts-foundation/nuts-node/crypto/storage"
+ "github.com/nuts-foundation/nuts-node/crypto/test"
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSignJWT(t *testing.T) {
+ claims := map[string]interface{}{"iss": "nuts"}
+ t.Run("creates valid JWT using rsa keys", func(t *testing.T) {
+ rsaKey := test.GenerateRSAKey()
+ key, _ := jwkKey(rsaKey)
+ tokenString, err := SignJWT(key, claims, nil)
+
+ assert.Nil(t, err)
+
+ token, err := ParseJWT(tokenString, func(kid string) (crypto.PublicKey, error) {
+ return rsaKey.Public(), nil
+ })
+
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ assert.Equal(t, "nuts", token.Issuer())
+ })
+
+ t.Run("creates valid JWT using ec keys", func(t *testing.T) {
+ p256, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
+ p521, _ := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
+
+ keys := []*ecdsa.PrivateKey{p256, p384, p521}
+
+ for _, ecKey := range keys {
+ name := fmt.Sprintf("using %s", ecKey.Params().Name)
+ t.Run(name, func(t *testing.T) {
+ key, _ := jwkKey(ecKey)
+ tokenString, err := SignJWT(key, claims, nil)
+
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ token, err := ParseJWT(tokenString, func(kid string) (crypto.PublicKey, error) {
+ return ecKey.Public(), nil
+ })
+
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Equal(t, "nuts", token.Issuer())
+ })
+ }
+ })
+
+ t.Run("sets correct headers", func(t *testing.T) {
+ ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ key, _ := jwkKey(ecKey)
+ tokenString, err := SignJWT(key, claims, nil)
+
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ msg, err := jws.ParseString(tokenString)
+
+ hdrs := msg.Signatures()[0].ProtectedHeaders()
+ alg, _ := hdrs.Get(jwk.AlgorithmKey)
+ assert.Equal(t, jwa.ES256, alg)
+ })
+}
+
+func TestCrypto_SignJWT(t *testing.T) {
+ client := createCrypto(t)
+
+ kid := "kid"
+ client.New(StringNamingFunc(kid))
+
+ t.Run("creates valid JWT", func(t *testing.T) {
+ tokenString, err := client.SignJWT(map[string]interface{}{"iss": "nuts"}, kid)
+ println(tokenString)
+
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ token, err := ParseJWT(tokenString, func(kid string) (crypto.PublicKey, error) {
+ return client.GetPublicKey(kid)
+ })
+
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ assert.Equal(t, "nuts", token.Issuer())
+ })
+
+ t.Run("returns error for not found", func(t *testing.T) {
+ _, err := client.SignJWT(map[string]interface{}{"iss": "nuts"}, "unknown")
+
+ assert.True(t, errors.Is(err, storage.ErrNotFound))
+ })
+}
+
+func TestCrypto_convertHeaders(t *testing.T) {
+ t.Run("nil headers", func(t *testing.T) {
+ jwtHeader := convertHeaders(nil)
+ assert.Len(t, jwtHeader.PrivateParams(), 0)
+ })
+
+ t.Run("ok", func(t *testing.T) {
+ rawHeaders := map[string]interface{} {
+ "key": "value",
+ }
+
+ jwtHeader := convertHeaders(rawHeaders)
+ v, _ := jwtHeader.Get("key")
+ assert.Equal(t, "value", v)
+ })
+}
diff --git a/crypto/log/log.go b/crypto/log/log.go
new file mode 100644
index 0000000000..5aaaa6ee87
--- /dev/null
+++ b/crypto/log/log.go
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2020. Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package log
+
+import (
+ "github.com/sirupsen/logrus"
+)
+
+var _logger = logrus.StandardLogger().WithField("module", "Crypto")
+
+// Logger returns a logger with the module field set to 'Crypto'
+func Logger() *logrus.Entry {
+ return _logger
+}
diff --git a/crypto/mock/mock_crypto.go b/crypto/mock/mock_crypto.go
new file mode 100644
index 0000000000..2d0a43db0e
--- /dev/null
+++ b/crypto/mock/mock_crypto.go
@@ -0,0 +1,110 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: crypto/interface.go
+
+// Package mock is a generated GoMock package.
+package mock
+
+import (
+ crypto "crypto"
+ gomock "github.com/golang/mock/gomock"
+ crypto0 "github.com/nuts-foundation/nuts-node/crypto"
+ reflect "reflect"
+)
+
+// MockKeyStore is a mock of KeyStore interface
+type MockKeyStore struct {
+ ctrl *gomock.Controller
+ recorder *MockKeyStoreMockRecorder
+}
+
+// MockKeyStoreMockRecorder is the mock recorder for MockKeyStore
+type MockKeyStoreMockRecorder struct {
+ mock *MockKeyStore
+}
+
+// NewMockKeyStore creates a new mock instance
+func NewMockKeyStore(ctrl *gomock.Controller) *MockKeyStore {
+ mock := &MockKeyStore{ctrl: ctrl}
+ mock.recorder = &MockKeyStoreMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockKeyStore) EXPECT() *MockKeyStoreMockRecorder {
+ return m.recorder
+}
+
+// New mocks base method
+func (m *MockKeyStore) New(namingFunc crypto0.KidNamingFunc) (crypto.PublicKey, string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "New", namingFunc)
+ ret0, _ := ret[0].(crypto.PublicKey)
+ ret1, _ := ret[1].(string)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// New indicates an expected call of New
+func (mr *MockKeyStoreMockRecorder) New(namingFunc interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "New", reflect.TypeOf((*MockKeyStore)(nil).New), namingFunc)
+}
+
+// GetPrivateKey mocks base method
+func (m *MockKeyStore) GetPrivateKey(kid string) (crypto.Signer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPrivateKey", kid)
+ ret0, _ := ret[0].(crypto.Signer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPrivateKey indicates an expected call of GetPrivateKey
+func (mr *MockKeyStoreMockRecorder) GetPrivateKey(kid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPrivateKey", reflect.TypeOf((*MockKeyStore)(nil).GetPrivateKey), kid)
+}
+
+// GetPublicKey mocks base method
+func (m *MockKeyStore) GetPublicKey(kid string) (crypto.PublicKey, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPublicKey", kid)
+ ret0, _ := ret[0].(crypto.PublicKey)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPublicKey indicates an expected call of GetPublicKey
+func (mr *MockKeyStoreMockRecorder) GetPublicKey(kid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPublicKey", reflect.TypeOf((*MockKeyStore)(nil).GetPublicKey), kid)
+}
+
+// SignJWT mocks base method
+func (m *MockKeyStore) SignJWT(claims map[string]interface{}, kid string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SignJWT", claims, kid)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// SignJWT indicates an expected call of SignJWT
+func (mr *MockKeyStoreMockRecorder) SignJWT(claims, kid interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignJWT", reflect.TypeOf((*MockKeyStore)(nil).SignJWT), claims, kid)
+}
+
+// PrivateKeyExists mocks base method
+func (m *MockKeyStore) PrivateKeyExists(key string) bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PrivateKeyExists", key)
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// PrivateKeyExists indicates an expected call of PrivateKeyExists
+func (mr *MockKeyStoreMockRecorder) PrivateKeyExists(key interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrivateKeyExists", reflect.TypeOf((*MockKeyStore)(nil).PrivateKeyExists), key)
+}
diff --git a/crypto/storage/fs.go b/crypto/storage/fs.go
new file mode 100644
index 0000000000..5b509b6426
--- /dev/null
+++ b/crypto/storage/fs.go
@@ -0,0 +1,189 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019 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 storage
+
+import (
+ "crypto"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+
+ "github.com/lestrrat-go/jwx/jwk"
+ "github.com/nuts-foundation/nuts-node/crypto/util"
+)
+
+type entryType string
+
+const (
+ privateKeyEntry entryType = "private.pem"
+ publicKeyEntry entryType = "public.pem"
+)
+
+type fileOpenError struct {
+ filePath string
+ kid string
+ err error
+}
+
+// Error returns the string representation
+func (f *fileOpenError) Error() string {
+ return fmt.Sprintf("could not open entry %s with filename %s: %v", f.kid, f.filePath, f.err)
+}
+
+// Unwrap is needed for fileOpenError to be UnWrapped
+func (f *fileOpenError) Unwrap() error {
+ return f.err
+}
+
+type fileSystemBackend struct {
+ fspath string
+}
+
+// NewFileSystemBackend creates a new filesystem backend, all directories will be created for the given path
+// Using a filesystem backend in production is not recommended!
+func NewFileSystemBackend(fspath string) (Storage, error) {
+ if fspath == "" {
+ return nil, errors.New("filesystem path is empty")
+ }
+ fsc := &fileSystemBackend{
+ fspath,
+ }
+
+ err := fsc.createDirs()
+
+ if err != nil {
+ return nil, err
+ }
+
+ return fsc, nil
+}
+
+func (fsc *fileSystemBackend) PrivateKeyExists(kid string) bool {
+ _, err := os.Stat(fsc.getEntryPath(kid, privateKeyEntry))
+ return err == nil
+}
+
+// Load the privatekey for the given legalEntity from disk. Since a legalEntity has a URI as identifier, the URI is base64 encoded and postfixed with '_private.pem'. Keys are stored in pem format and are 2k RSA keys.
+func (fsc *fileSystemBackend) GetPrivateKey(kid string) (crypto.Signer, error) {
+ data, err := fsc.readEntry(kid, privateKeyEntry)
+ if err != nil {
+ return nil, err
+ }
+ privateKey, err := util.PemToPrivateKey(data)
+ if err != nil {
+ return nil, err
+ }
+ return privateKey, nil
+}
+
+// Load the public key from disk, it load the private key and extract the public key from it.
+func (fsc *fileSystemBackend) GetPublicKey(kid string) (crypto.PublicKey, error) {
+ data, err := fsc.readEntry(kid, publicKeyEntry)
+ if err != nil {
+ return nil, err
+ }
+ publicKey, err := util.PemToPublicKey(data)
+ if err != nil {
+ return nil, err
+ }
+ return publicKey, nil
+}
+
+// Save the private key for the given key to disk. Files are postfixed with '_private.pem'. Keys are stored in pem format. It also store the public key
+func (fsc *fileSystemBackend) SavePrivateKey(kid string, key crypto.PrivateKey) error {
+ filenamePath := fsc.getEntryPath(kid, privateKeyEntry)
+ outFile, err := os.Create(filenamePath)
+
+ if err != nil {
+ return err
+ }
+
+ defer outFile.Close()
+
+ pem, err := util.PrivateKeyToPem(key)
+ if err != nil {
+ return err
+ }
+
+ _, err = outFile.Write([]byte(pem))
+ if err != nil {
+ return err
+ }
+
+ return fsc.SavePublicKey(kid, key)
+}
+
+// Save the private key for the given key to disk. Files are postfixed with '_private.pem'. Keys are stored in pem format. It also store the public key
+func (fsc *fileSystemBackend) SavePublicKey(kid string, key crypto.PrivateKey) error {
+ filenamePath := fsc.getEntryPath(kid, publicKeyEntry)
+ outFile, err := os.Create(filenamePath)
+
+ if err != nil {
+ return err
+ }
+
+ publicKey, err := jwk.PublicKeyOf(key)
+
+ defer outFile.Close()
+
+ pem, err := util.PublicKeyToPem(publicKey)
+ if err != nil {
+ return err
+ }
+
+ _, err = outFile.Write([]byte(pem))
+
+ return err
+}
+
+func (fsc fileSystemBackend) readEntry(kid string, entryType entryType) ([]byte, error) {
+ filePath := fsc.getEntryPath(kid, entryType)
+ data, err := ioutil.ReadFile(filePath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, &fileOpenError{kid: kid, filePath: filePath, err: ErrNotFound}
+ }
+ return nil, &fileOpenError{kid: kid, filePath: filePath, err: err}
+ }
+ return data, nil
+}
+
+func (fsc fileSystemBackend) getEntryPath(key string, entryType entryType) string {
+ return filepath.Join(fsc.fspath, getEntryFileName(key, entryType))
+}
+
+func (fsc *fileSystemBackend) createDirs() error {
+ f, err := os.Open(fsc.fspath)
+
+ if f != nil {
+ f.Close()
+ }
+
+ if err != nil {
+ err = os.MkdirAll(fsc.fspath, os.ModePerm)
+ }
+
+ return err
+}
+
+func getEntryFileName(kid string, entryType entryType) string {
+ return fmt.Sprintf("%s_%s", kid, entryType)
+}
diff --git a/crypto/storage/fs_test.go b/crypto/storage/fs_test.go
new file mode 100644
index 0000000000..93c344d7d7
--- /dev/null
+++ b/crypto/storage/fs_test.go
@@ -0,0 +1,103 @@
+package storage
+
+import (
+ "os"
+ "testing"
+
+ "github.com/nuts-foundation/nuts-node/crypto/test"
+ "github.com/nuts-foundation/nuts-node/test/io"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_NewFileSystemBackend(t *testing.T) {
+ t.Run("error - path is empty", func(t *testing.T) {
+ storage, err := NewFileSystemBackend("")
+ assert.EqualError(t, err, "filesystem path is empty")
+ assert.Nil(t, storage)
+ })
+}
+
+func Test_fs_GetPublicKey(t *testing.T) {
+ t.Run("non-existing entry", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+ key, err := storage.GetPublicKey("unknown")
+ assert.Contains(t, err.Error(), "could not open entry unknown with filename")
+ assert.Nil(t, key)
+ })
+ t.Run("ok", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+ pk := test.GenerateECKey()
+ kid := "kid"
+
+ err := storage.(*fileSystemBackend).SavePublicKey(kid, pk)
+
+ if !assert.NoError(t, err) {
+ return
+ }
+ key, err := storage.GetPublicKey(kid)
+ assert.NoError(t, err)
+ if !assert.NotNil(t, key) {
+ return
+ }
+ assert.Equal(t, &pk.PublicKey, key)
+ })
+}
+
+func Test_fs_GetPrivateKey(t *testing.T) {
+ t.Run("non-existing entry", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+
+ key, err := storage.GetPrivateKey("unknown")
+
+ assert.Contains(t, err.Error(), "could not open entry unknown with filename")
+ assert.Nil(t, key)
+ })
+ t.Run("private key invalid", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+ kid := "kid"
+ path := storage.(*fileSystemBackend).getEntryPath(kid, privateKeyEntry)
+ file, _ := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
+ _, err := file.WriteString("hello world")
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ key, err := storage.GetPrivateKey(kid)
+
+ assert.Nil(t, key)
+ assert.Error(t, err)
+ })
+ t.Run("ok", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+ pk := test.GenerateECKey()
+ kid := "kid"
+
+ err := storage.SavePrivateKey(kid, pk)
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ key, err := storage.GetPrivateKey(kid)
+
+ assert.NoError(t, err)
+ if !assert.NotNil(t, key) {
+ return
+ }
+ assert.Equal(t, pk, key)
+ })
+}
+
+func Test_fs_KeyExistsFor(t *testing.T) {
+ t.Run("non-existing entry", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+ assert.False(t, storage.PrivateKeyExists("unknown"))
+ })
+ t.Run("existing entry", func(t *testing.T) {
+ storage, _ := NewFileSystemBackend(io.TestDirectory(t))
+ pk := test.GenerateECKey()
+ kid := "kid"
+ storage.SavePrivateKey(kid, pk)
+ assert.True(t, storage.PrivateKeyExists(kid))
+ })
+}
diff --git a/crypto/storage/storage.go b/crypto/storage/storage.go
new file mode 100644
index 0000000000..9f12a7b960
--- /dev/null
+++ b/crypto/storage/storage.go
@@ -0,0 +1,35 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019 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 storage
+
+import (
+ "crypto"
+ "errors"
+)
+
+// ErrNotFound indicates that the specified crypto storage entry couldn't be found.
+var ErrNotFound = errors.New("entry not found")
+
+// Storage interface containing functions for storing and retrieving keys
+type Storage interface {
+ GetPrivateKey(kid string) (crypto.Signer, error)
+ GetPublicKey(kid string) (crypto.PublicKey, error)
+ PrivateKeyExists(kid string) bool
+ SavePrivateKey(kid string, key crypto.PrivateKey) error
+}
diff --git a/crypto/test.go b/crypto/test.go
new file mode 100644
index 0000000000..4a9ccbe7d6
--- /dev/null
+++ b/crypto/test.go
@@ -0,0 +1,36 @@
+package crypto
+
+import (
+ "crypto"
+ "path"
+
+ "github.com/sirupsen/logrus"
+)
+
+// NewTestCryptoInstance returns a new Crypto instance to be used for integration tests. Any data is stored in the
+// specified test directory.
+func NewTestCryptoInstance(testDirectory string) *Crypto {
+ config := TestCryptoConfig(testDirectory)
+ newInstance := &Crypto{
+ Config: config,
+ }
+ if err := newInstance.Configure(); err != nil {
+ logrus.Fatal(err)
+ }
+ instance = newInstance
+ return newInstance
+}
+
+// TestCryptoConfig returns Config to be used in integration/unit tests.
+func TestCryptoConfig(testDirectory string) Config {
+ config := DefaultCryptoConfig()
+ config.Fspath = path.Join(testDirectory, "crypto")
+ return config
+}
+
+// StringNamingFunc can be used to give a key a simple string name
+func StringNamingFunc(name string) KidNamingFunc {
+ return func(key crypto.PublicKey) string {
+ return name
+ }
+}
diff --git a/crypto/test/broken.pem b/crypto/test/broken.pem
new file mode 100644
index 0000000000..0d8fe47f4c
--- /dev/null
+++ b/crypto/test/broken.pem
@@ -0,0 +1,6 @@
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+2FbJ7Gct/oZMb9pEjI
+ZX7ouh77hOBUyB1kqglFDZMTg4RaT5wFyAFtL+LJMQ4Alzsm0XXpyO1Aw253AJD6
+FQPkc0BBGeqXiXplsznIPujDjJ4na+znzuqnV7hNQ5frhAc8c5Zv3EKoh+lO4Dqn
+21kkNYvd3iD2qVafEQEKlQmiEmjVTo/jkQvI833KbbR14HJAtYXrGN8xPqg0rAXU
+kRAa6nmP2lLW7QpH8aEYR3EAlL4dzRWggvIaX0qG6/JHph0n0MHlFwryIndlir42
+bujRuOsp0qGirwj4DY6usgNOVUv9WClQpdlYyyZd/gnYby9P7wF5tch2Gtwfxpk0
diff --git a/crypto/test/ec.sk b/crypto/test/ec.sk
new file mode 100644
index 0000000000..d263186e15
--- /dev/null
+++ b/crypto/test/ec.sk
@@ -0,0 +1,6 @@
+-----BEGIN EC PRIVATE KEY-----
+MIGkAgEBBDDkxTzFUFbIvBZWH6UlibvdXrhqYOzDb1lKTVSWX8P8OLbuu7Dzp9Ru
+1LGfzY1juuygBwYFK4EEACKhZANiAATAncgtph3ACHSPXWyvyYop/71skjBK6Q1T
+UB6WFs6pusiUD1pYDMZ01IjBx/cMJaJP/VoyYl24Wbf2/mBnKt1lfDzYYVf0kFxT
+dtTkGJrJAzbtHuysgU+GrEdjYSfhDKc=
+-----END EC PRIVATE KEY-----
diff --git a/crypto/test/ed25519.sk b/crypto/test/ed25519.sk
new file mode 100644
index 0000000000..bca7a3b30b
--- /dev/null
+++ b/crypto/test/ed25519.sk
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIC4ylI95t5FYJ6vcOhUSWB6YARTgyANe8bjAJJh9IerJ
+-----END PRIVATE KEY-----
diff --git a/crypto/test/keys.go b/crypto/test/keys.go
new file mode 100644
index 0000000000..8b6ef2eb1b
--- /dev/null
+++ b/crypto/test/keys.go
@@ -0,0 +1,23 @@
+package test
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+)
+
+// GenerateRSAKey generates a 1024 bits RSA key
+func GenerateRSAKey() *rsa.PrivateKey {
+ privateKey, err := rsa.GenerateKey(rand.Reader, 1024)
+ if err != nil {
+ panic(err)
+ }
+ return privateKey
+}
+
+// GenerateECKey generates a P-256 EC key
+func GenerateECKey() *ecdsa.PrivateKey {
+ key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ return key
+}
diff --git a/crypto/test/public_2048.pem b/crypto/test/public_2048.pem
new file mode 100644
index 0000000000..d23e9fb9d3
--- /dev/null
+++ b/crypto/test/public_2048.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr8LufriX1x/jrXrGp3Ph
+yuEca6wm0pe9AG7Ufwgb8FwA3clKPhiPH8DgO2BiEmARM40+JErU9funhdlgmYEj
+YOWJVLRyK2LzjxqqxSeJfTAxXeK6c5Ia6oFXFJu0yBH83YYIuVKtjGeHtJJJsnLh
+lrbZ/MAGBcw8hXJiSCSOZSPMe7uwDO8luLtiz5v8Olg4fhzkM269SWn2VbyZwMxp
+wDh5pMvAIVrWq8xB3vUeeHJednRz3D+xWceLtWvji6P8SMoc9pvQSRX/mUTXNvjR
+GYO4MPGWD/cgvIzUL72A4npfYAY3dXEYT7j9qSyvA0oaynsx3TXbsopnHoX2Nerc
+RQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/crypto/test/publickey.pem b/crypto/test/publickey.pem
new file mode 100644
index 0000000000..ef3ccaccc7
--- /dev/null
+++ b/crypto/test/publickey.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+2FbJ7Gct/oZMb9pEjI
+ZX7ouh77hOBUyB1kqglFDZMTg4RaT5wFyAFtL+LJMQ4Alzsm0XXpyO1Aw253AJD6
+FQPkc0BBGeqXiXplsznIPujDjJ4na+znzuqnV7hNQ5frhAc8c5Zv3EKoh+lO4Dqn
+21kkNYvd3iD2qVafEQEKlQmiEmjVTo/jkQvI833KbbR14HJAtYXrGN8xPqg0rAXU
+kRAa6nmP2lLW7QpH8aEYR3EAlL4dzRWggvIaX0qG6/JHph0n0MHlFwryIndlir42
+bujRuOsp0qGirwj4DY6usgNOVUv9WClQpdlYyyZd/gnYby9P7wF5tch2Gtwfxpk0
+DQIDAQAB
+-----END PUBLIC KEY-----
\ No newline at end of file
diff --git a/crypto/test/rsa.sk b/crypto/test/rsa.sk
new file mode 100644
index 0000000000..0bf6bf48b7
--- /dev/null
+++ b/crypto/test/rsa.sk
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAvI38T8kibml2OZdPOenptshXfHQjcBIVHI3ag5na8zaxu/A8
+QYW0n8jLbtecaGaxFxoPYzxUoEqyKegdaPq3IMPnmWgM+cleI5npUB7/lWFOi0qs
+VJ5oEKYTHiAVm0UdqMNyVIEAS+Q9NM5gYQn332kQeKD9yw4+1tj1ZBbhgIgVNerO
+LNRNIexgqBHgDBrBV7GtxtjB4bnSC50eUm2DaxLqjGGONz6wr5qlH1Nz/sK+1qkf
+uiS/BBcLMjX7qX42qBKSWljXQoON7scRKq7ng8UXkgD9eFM4dngmDsYhGu3OlbUe
+X9DUHx5tzWjK0fsIN5+xJrsnibt7GTSA0+BpIwIDAQABAoIBAF+6koQLcPCvmHdq
+y61yhdbn5groh+lpNNC3cF5qKZBbj2cEdAMsF+Ubs7UFwIH8ySIad/+A7NIoukWu
+c+gNihYXgCkRe60BwErA5IRqCIbVzWPIuK+JcPoq5v/feDjJoWJCQHSIvhbJvQ1n
+TeVWg0Mo+1TTH6cNB3ha1FNJjpmLHPcnteYx67Z9nawHEK3Gmxm0BhFnD1Fag0zu
+EETtl80j86inRsL4HnPHAgi8acu3jUFtYwcixIqscDclG6Vvmm6S00P1fhTeW3zs
+jvmi5ibD8d4ygArQHDDBA+o8KrIzy1/H6Le41BuxCX6mim76HGxbipmJCvp4yXdD
+GPCedtkCgYEAxAkVAvg8tj4aYAt/ovPmyk/yb7C7D2Q8HFFOqVSdwAEsEFacADTu
+8/FOFqr7bUu4z3cZdA00cutgHUDDOmzcpPlVVsvuOZ2zkiCa1C8l+dACF/0YSuJH
+J6A7nktV+cJhbQPIKhbAYDqaKahGBnOQi3vI5I5rJgii2AzpRSvvJscCgYEA9jsa
+J2UQtTlSBTcfBES3xUN5WkgGiHCRCihRsdHXg2ESL6V87bY+F4RgKXs5+jAjrdwT
+JBLifzBCFSmMxQSgCAOILyu2hBzRpIvftxdjSp3bOrdSMeMtdRb6sDFuCdRDTrcp
+eoBHhPoS7O/TBs1H//8Hx3B/y+1xpAZXloIR3sUCgYAL8y3Ht5Aj39dFwY2vRkTs
+UkFKE7DjeE29wCsWYWUYXjnsaQsrbA6g6jXDZfrbp8EFTJJNo7xtwPFj9x2vgxFU
+MSrFlrrX4kgfAUPO6WzcNJTcF36SmgaSYM8hkCAWkIXV2mQqRKbHdusM3Qgfvo2y
+IwKVBCV99QrQNsFFiS8T4wKBgDBKVQ3G12kDTd+x+MZIh9YLLqCTIZzensNkNult
+4xtkDUIE7aRdKn5IOufHwA4eJNEzKRnZDkytdThbRr1Y892+e5Xst8XfNQpVWFG7
+J4D5xoYUb+1SxZaCJDYr643H8E9ewqbAw8YDmXSYcEWUOvus06S8noOrFK97gvAE
+oGaVAoGBAKpfPLP8hit0hHd4baHRn117K/CRsEyXXuW2wObO+hmvEolvJ77KuqGf
+lRKjXO2gbKrN/DlUwhdUAuBkxLDhWB2aaMlBUsu7lHKqFgH3RkDTFE2qy+8iw/gC
+AOExzvIXyUGMiK4T7esSYA10Gjx037KOGBY4YHvsucKP5Y8yKN7n
+-----END RSA PRIVATE KEY-----
diff --git a/crypto/test/sk.pem b/crypto/test/sk.pem
new file mode 100644
index 0000000000..86ade6e98e
--- /dev/null
+++ b/crypto/test/sk.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDwMR1Stjz9CFiZ
+ZX9TfbigNNpj/UdLapdi5j7ZMrCydHSdMX4+cvJHYHZ7UUrVM9N/Pn8OJpTqBQi/
+wH/9EPnu5r4KGKKK+1i+FlFf8AZPs6aiH7EIgZGJWAxQfxIVvViMJmcDnAiDNYk9
+uyPCunm+eT4Q30Lka6gbT/Q33MhngaLqd82i/w5amuNcxmcnsl6yJwvoRp9gAKM+
+a8E9ds3qYPDDeUW6R3XMSVw+9S+FePiLx4hOA6JDSiAGhzcKVRaJHnWE3+dMUSyK
+wW/tNUUsoTGBVsGzcDjeuxXHhJlROsQuDSyukeeKRmHOvM/qXP7D+RPjK2eXVtnX
+cKaf3CBPAgMBAAECggEBAI4KPXEAXCi2ms57+0QAgQIcv1mKGnM64bOWvWvsUhn2
+TE/5SNLdXvFe1jIu7LYQI+6HotNXdZC+0WG0EPwy4dqpkZCe6OCPqvcec5jsdI8F
+inEtlJ+XH928t+uSebtpccfPnfPAfjg5nKNo28j4Ra6iPFX18bTrBUrBiYbPhaPP
+HISw/FiZeBsvzY9/dbx3/i36W3R0utqgeQ6cZWxwIT5RVInazLo+s+E4w6hOb9bO
+HeO7jtLu8SLaJZwqooZ0cI5abYpDNJI9OU4J489M3zo6ZJiWMwhvwUwok15JKbZ/
+MsrekHaiu7iUd50D9Mirg6iB0bJDR3xQmpN2tZIcnCECgYEA/0Et0h1vG2wmG2VL
+gt3b8HU4GKF5UkPjksYEys/HLXuW3oNygiqJWWZNwy8pDnHqd4urunxuFK/prMxm
+43eWEzQIHdEK0rfpfak1FUP4FGlOMp3vMvw0yuPVvaRzpRN94SHirYUVwDR5lrZ0
+T9+j3LpSfcTB31eGOkn/ln6lEOUCgYEA8OSs0b0P9R7Bc7ZliOwwCRvt24gCcJky
+yPqtlm7P2Z6fP854Yvu0nItP+6kVFB8dz4o7VammOYQwYs3Lrg92poVXDNGazUIJ
+YxT/JPAY+d1WwVvee/BdKJ+mOda6+lIGEQi440QY0aCtiQEdPGXt0NE0RYFvM4s/
+k0rZtmwvfSMCgYEA3yZONpiA38pmbiDaKOhoNQllJzNTavXq6A+xdNS83ihjttfX
+rbAeL0fex7pc/EHepvA2C2xomDFJ6kUv1cBgNR2R0u9DtQAPYkohHBw1rzJ4qIul
+6D7QsGcKHya76x7lN4J2NxhX8ZZujbGocYOkL328TDNNAkH0GNVEWn8RM3kCgYA4
+EaG/97d9IDl6y1t6sS7FEAEe9dtLhfzyFpbMyuIKDweV/GK890UkorBtLP/A/TUd
+F1mUKLaN8JyqgqgDzYmaXLLUQv07BUHWFA8G8/N8RO5qdw2j32BvkilIkRhYJztO
+P695BmKYeEOr/dxmMHtX/Tmja+sMHj8f824VLb0n7QKBgEuAqdCao4fm9WKFhUPV
+XdqXqgqoRgvOVP6qwleIhv/TwFEdW9ZVC1K1h7nvXhlvW71Cm95jyv8DDCfvqlG5
+Bupi1fe4UHfb7h5vldOzY2qr8wfJ5+I+vcqo7p4s8Y7P0ZmTEO/1bVG0GFVa32Gq
+B6Moz2dS/7ICo0Itc2OI7RC8
+-----END PRIVATE KEY-----
\ No newline at end of file
diff --git a/crypto/util/common.go b/crypto/util/common.go
new file mode 100644
index 0000000000..8070c7fe8f
--- /dev/null
+++ b/crypto/util/common.go
@@ -0,0 +1,32 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 util
+
+import (
+ "errors"
+)
+
+// ErrWrongPublicKey indicates a wrong public key format
+var ErrWrongPublicKey = errors.New("failed to decode PEM block containing public key, key is of the wrong type")
+
+// ErrWrongPrivateKey indicates a wrong private key format
+var ErrWrongPrivateKey = errors.New("failed to decode PEM block containing private key")
+
+// ErrRsaPubKeyConversion indicates a public key could not be converted to an RSA public key
+var ErrRsaPubKeyConversion = errors.New("Unable to convert public key to RSA public key")
diff --git a/crypto/util/jwk.go b/crypto/util/jwk.go
new file mode 100644
index 0000000000..dd39272698
--- /dev/null
+++ b/crypto/util/jwk.go
@@ -0,0 +1,97 @@
+/*
+ * 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 util
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+
+ "github.com/lestrrat-go/jwx/jwk"
+ errors2 "github.com/pkg/errors"
+)
+
+// MapToJwk transforms a Jwk in map structure to a Jwk Key. The map structure is a typical result from json deserialization.
+func MapToJwk(jwkAsMap map[string]interface{}) (jwk.Key, error) {
+ set, err := MapsToJwkSet([]map[string]interface{}{jwkAsMap})
+ if err != nil {
+ return nil, err
+ }
+ return set.Keys[0], nil
+}
+
+// MapsToJwkSet transforms JWKs in map structures to a JWK set, just like MapToJwk.
+func MapsToJwkSet(maps []map[string]interface{}) (*jwk.Set, error) {
+ set := &jwk.Set{Keys: make([]jwk.Key, len(maps))}
+ for i, m := range maps {
+ jwkBytes, err := json.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ key, err := jwk.ParseKey(jwkBytes)
+ if err != nil {
+ return nil, err
+ }
+ set.Keys[i] = key
+ }
+ return set, nil
+}
+
+// ValidateJWK tests whether the given map (all) can is a parsable representation of a JWK. If not, an error is returned.
+// If nil is returned, all supplied maps are parsable as JWK.
+func ValidateJWK(maps ...interface{}) error {
+ var stringMaps []map[string]interface{}
+ for _, currMap := range maps {
+ keyAsMap, ok := currMap.(map[string]interface{})
+ if !ok {
+ return errors.New("invalid JWK, it is not map[string]interface{}")
+ }
+ stringMaps = append(stringMaps, keyAsMap)
+ }
+ if _, err := MapsToJwkSet(stringMaps); err != nil {
+ return errors2.Wrap(err, "invalid JWK")
+ }
+ return nil
+}
+
+// deepCopyMap is needed since the jwkSet.extractMap consumes the contents
+func deepCopyMap(m map[string]interface{}) map[string]interface{} {
+ cp := make(map[string]interface{})
+ for k, v := range m {
+ vm, ok := v.(map[string]interface{})
+ if ok {
+ cp[k] = deepCopyMap(vm)
+ } else {
+ cp[k] = v
+ }
+ }
+ return cp
+}
+
+// JwkToMap transforms a Jwk key to a map. Can be used for json serialization
+func JwkToMap(key jwk.Key) (map[string]interface{}, error) {
+ return key.AsMap(context.Background())
+}
+
+// PemToJwk transforms pem to jwk for PublicKey
+func PemToJwk(pub []byte) (jwk.Key, error) {
+ pk, err := PemToPublicKey(pub)
+ if err != nil {
+ return nil, err
+ }
+
+ return jwk.New(pk)
+}
diff --git a/crypto/util/jwk_test.go b/crypto/util/jwk_test.go
new file mode 100644
index 0000000000..92711eae77
--- /dev/null
+++ b/crypto/util/jwk_test.go
@@ -0,0 +1,146 @@
+/*
+ * 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 util
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+
+ "github.com/lestrrat-go/jwx/jwa"
+ "github.com/lestrrat-go/jwx/jwk"
+ "github.com/nuts-foundation/nuts-node/crypto/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestJwkToMap(t *testing.T) {
+ t.Run("Generates map for RSA key", func(t *testing.T) {
+ rsa := test.GenerateRSAKey()
+ jwk, _ := jwk.New(rsa)
+
+ jwkMap, err := JwkToMap(jwk)
+
+ if assert.NoError(t, err) {
+ assert.Equal(t, jwa.KeyType("RSA"), jwkMap["kty"])
+ }
+ })
+}
+
+func TestMapToJwk(t *testing.T) {
+ t.Run("Generates Jwk from map", func(t *testing.T) {
+ jwkAsJSON := `{"d":"Ce3obeVsZeU3QaKBTQ-Qn-EaUfhEVViHbnP3gnLDrXNbiUf09s0Ti3RXd4601G8fAJ3zKlZmdEop59mK5BjAE8NOBmvP4uI7PYlJsDAE76mKghVxvN94qb-KwW4p0wix9RoC8TEtoE3EYCr428v-k4nTpMWXQcC_xkHVIfpoA6E","dp":"LGJtrCIxo2DlCSccu0ivH8YzUS9uUbsKyOgNEpV3IB3vqZToi_k8TkwN9XNXCMXkRYIGtRwkxvp9TWLtIEKMtQ","dq":"XhBVCRvFE_ccZ7rxzfu7LToeSNBPW07v68tM94pEV2MFfVBHdWJd-gHbIPGVwC55Th9vAh9dDmv0TvBVkiblkQ","e":"AQAB","kty":"RSA","n":"n5KqvPI1MPDhazTKXLYn4_we09e3iEccb7QJ8dRxApN1rpxTymRWabUafC56fArDF0lvIZ7fZl0LzX5Z_3mrqulebEPTFRrbdDwwcqa2KZ7Tctfh6MgUFm5xOAwRG33NlX3Ny1dP-Ek2irXJOHt9AecbEZFZKmpgrsrTyG6Ekfs","p":"1LoOk3MFiJpsjJCkMkaDb0TXXMxuZ5f9-iMVgR1ZoammzQziBj-72CrD21Rxmuuc6en8w4HtHLSOlPQtcOKzMw","q":"wAiSzr1NVdsYulhGYAa1ONZSKVxlFS7N_UAjPQgFf-xTYog2RbZfolheDv92mJp2qqFJdVMzQkbeMeTj9xqmGQ","qi":"eFqCOgR0wnpkjZGwh63pV8aNhh1-GfhYjqF2jSrh6rnsVHnhz3LRROSzUDarms7LjW3eHiygyHHSF2-ejTMMKQ"}`
+
+ jwkMap := map[string]interface{}{}
+ json.Unmarshal([]byte(jwkAsJSON), &jwkMap)
+
+ jwk, err := MapToJwk(jwkMap)
+
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Equal(t, jwa.KeyType("RSA"), jwk.KeyType())
+ assert.NotNil(t, jwkMap["d"], "function altered input map")
+ })
+
+ t.Run("with missing data", func(t *testing.T) {
+ jwkMap := map[string]interface{}{}
+ _, err := MapToJwk(jwkMap)
+
+ assert.Error(t, err)
+ })
+}
+
+func TestMapsToJwkSet(t *testing.T) {
+ t.Run("Generates set from maps", func(t *testing.T) {
+ jwkAsJSON := `{"d":"Ce3obeVsZeU3QaKBTQ-Qn-EaUfhEVViHbnP3gnLDrXNbiUf09s0Ti3RXd4601G8fAJ3zKlZmdEop59mK5BjAE8NOBmvP4uI7PYlJsDAE76mKghVxvN94qb-KwW4p0wix9RoC8TEtoE3EYCr428v-k4nTpMWXQcC_xkHVIfpoA6E","dp":"LGJtrCIxo2DlCSccu0ivH8YzUS9uUbsKyOgNEpV3IB3vqZToi_k8TkwN9XNXCMXkRYIGtRwkxvp9TWLtIEKMtQ","dq":"XhBVCRvFE_ccZ7rxzfu7LToeSNBPW07v68tM94pEV2MFfVBHdWJd-gHbIPGVwC55Th9vAh9dDmv0TvBVkiblkQ","e":"AQAB","kty":"RSA","n":"n5KqvPI1MPDhazTKXLYn4_we09e3iEccb7QJ8dRxApN1rpxTymRWabUafC56fArDF0lvIZ7fZl0LzX5Z_3mrqulebEPTFRrbdDwwcqa2KZ7Tctfh6MgUFm5xOAwRG33NlX3Ny1dP-Ek2irXJOHt9AecbEZFZKmpgrsrTyG6Ekfs","p":"1LoOk3MFiJpsjJCkMkaDb0TXXMxuZ5f9-iMVgR1ZoammzQziBj-72CrD21Rxmuuc6en8w4HtHLSOlPQtcOKzMw","q":"wAiSzr1NVdsYulhGYAa1ONZSKVxlFS7N_UAjPQgFf-xTYog2RbZfolheDv92mJp2qqFJdVMzQkbeMeTj9xqmGQ","qi":"eFqCOgR0wnpkjZGwh63pV8aNhh1-GfhYjqF2jSrh6rnsVHnhz3LRROSzUDarms7LjW3eHiygyHHSF2-ejTMMKQ"}`
+
+ jwkMap := map[string]interface{}{}
+ json.Unmarshal([]byte(jwkAsJSON), &jwkMap)
+
+ set, err := MapsToJwkSet([]map[string]interface{}{jwkMap})
+
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.Len(t, set.Keys, 1)
+ assert.NotNil(t, jwkMap["d"], "function altered input map")
+ })
+
+ t.Run("with missing data", func(t *testing.T) {
+ jwkMap := map[string]interface{}{}
+ _, err := MapsToJwkSet([]map[string]interface{}{jwkMap})
+
+ assert.Error(t, err)
+ })
+}
+
+func TestPemToJwk(t *testing.T) {
+ t.Run("generated jwk from pem", func(t *testing.T) {
+ pub := "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9wJQN59PYsvIsTrFuTqS\nLoUBgwdRfpJxOa5L8nOALxNk41MlAg7xnPbvnYrOHFucfWBTDOMTKBMSmD4WDkaF\ndVrXAML61z85Le8qsXfX6f7TbKMDm2u1O3cye+KdJe8zclK9sTFzSD0PP0wfw7wf\nlACe+PfwQgeOLPUWHaR6aDfaA64QEdfIzk/IL3S595ixaEn0huxMHgXFX35Vok+o\nQdbnclSTo6HUinkqsHUu/hGHApkE3UfT6GD6SaLiB9G4rAhlrDQ71ai872t4FfoK\n7skhe8sP2DstzAQRMf9FcetrNeTxNL7Zt4F/qKm80cchRZiFYPMCYyjQphyBCoJf\n0wIDAQAB\n-----END PUBLIC KEY-----"
+
+ jwk, err := PemToJwk([]byte(pub))
+
+ if assert.NoError(t, err) {
+ assert.Equal(t, jwa.KeyType("RSA"), jwk.KeyType())
+ }
+ })
+ t.Run("invalid PEM", func(t *testing.T) {
+ _, err := PemToJwk([]byte("hello world"))
+ assert.Error(t, err)
+ })
+}
+
+func Test_deepCopyMap(t *testing.T) {
+ expected := map[string]interface{}{}
+ expected["flat"] = "foobar"
+ expected["nested"] = map[string]interface{}{
+ "nested": map[string]interface{}{
+ "nested": map[string]interface{}{
+ "value": "ok",
+ },
+ },
+ }
+
+ actual := deepCopyMap(expected)
+ assert.True(t, reflect.DeepEqual(actual, expected))
+ // Assert it's actually a copy
+ delete(expected, "flat")
+ assert.False(t, reflect.DeepEqual(actual, expected))
+}
+
+func TestValidateJWK(t *testing.T) {
+ jwkAsJSON := `{
+ "e": "AQAB",
+ "kty": "RSA",
+ "n": "n5KqvPI1MPDhazTKXLYn4_we09e3iEccb7QJ8dRxApN1rpxTymRWabUafC56fArDF0lvIZ7fZl0LzX5Z_3mrqulebEPTFRrbdDwwcqa2KZ7Tctfh6MgUFm5xOAwRG33NlX3Ny1dP-Ek2irXJOHt9AecbEZFZKmpgrsrTyG6Ekfs",
+ "x5c": ["MIIE3jCCA8agAwIBAgICAwEwDQYJKoZIhvcNAQEFBQAwYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRoZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjExMTYwMTU0MzdaFw0yNjExMTYwMTU0MzdaMIHKMQswCQYDVQQGEwJVUzEQMA4GA1UECBMHQXJpem9uYTETMBEGA1UEBxMKU2NvdHRzZGFsZTEaMBgGA1UEChMRR29EYWRkeS5jb20sIEluYy4xMzAxBgNVBAsTKmh0dHA6Ly9jZXJ0aWZpY2F0ZXMuZ29kYWRkeS5jb20vcmVwb3NpdG9yeTEwMC4GA1UEAxMnR28gRGFkZHkgU2VjdXJlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MREwDwYDVQQFEwgwNzk2OTI4NzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMQt1RWMnCZM7DI161+4WQFapmGBWTtwY6vj3D3HKrjJM9N55DrtPDAjhI6zMBS2sofDPZVUBJ7fmd0LJR4h3mUpfjWoqVTr9vcyOdQmVZWt7/v+WIbXnvQAjYwqDL1CBM6nPwT27oDyqu9SoWlm2r4arV3aLGbqGmu75RpRSgAvSMeYddi5Kcju+GZtCpyz8/x4fKL4o/K1w/O5epHBp+YlLpyo7RJlbmr2EkRTcDCVw5wrWCs9CHRK8r5RsL+H0EwnWGu1NcWdrxcx+AuP7q2BNgWJCJjPOq8lh8BJ6qf9Z/dFjpfMFDniNoW1fho3/Rb2cRGadDAW/hOUoz+EDU8CAwEAAaOCATIwggEuMB0GA1UdDgQWBBT9rGEyk2xF1uLuhV+auud2mWjM5zAfBgNVHSMEGDAWgBTSxLDSkdRMEXGzYcs9of7dqGrU4zASBgNVHRMBAf8ECDAGAQH/AgEAMDMGCCsGAQUFBwEBBCcwJTAjBggrBgEFBQcwAYYXaHR0cDovL29jc3AuZ29kYWRkeS5jb20wRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NlcnRpZmljYXRlcy5nb2RhZGR5LmNvbS9yZXBvc2l0b3J5L2dkcm9vdC5jcmwwSwYDVR0gBEQwQjBABgRVHSAAMDgwNgYIKwYBBQUHAgEWKmh0dHA6Ly9jZXJ0aWZpY2F0ZXMuZ29kYWRkeS5jb20vcmVwb3NpdG9yeTAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBANKGwOy9+aG2Z+5mC6IGOgRQjhVyrEp0lVPLN8tESe8HkGsz2ZbwlFalEzAFPIUyIXvJxwqoJKSQ3kbTJSMUA2fCENZvD117esyfxVgqwcSeIaha86ykRvOe5GPLL5CkKSkB2XIsKd83ASe8T+5o0yGPwLPk9Qnt0hCqU7S+8MxZC9Y7lhyVJEnfzuz9p0iRFEUOOjZv2kWzRaJBydTXRE4+uXR21aITVSzGh6O1mawGhId/dQb8vxRMDsxuxN89txJx9OjxUUAiKEngHUuHqDTMBqLdElrRhjZkAzVvb3du6/KFUJheqwNTrZEjYx8WnM25sgVjOuH0aBsXBTWVU+4="]
+}`
+ key := map[string]interface{}{}
+ json.Unmarshal([]byte(jwkAsJSON), &key)
+ t.Run("ok", func(t *testing.T) {
+ assert.NoError(t, ValidateJWK(key, key))
+ })
+ t.Run("error - invalid Go type", func(t *testing.T) {
+ invalidMap := map[bool]interface{}{}
+ assert.Error(t, ValidateJWK(key, invalidMap))
+ })
+ t.Run("error - invalid JWK", func(t *testing.T) {
+ invalidMap := map[string]interface{}{
+ "kty": "foobar",
+ }
+ assert.Error(t, ValidateJWK(key, invalidMap))
+ })
+}
diff --git a/crypto/util/pem.go b/crypto/util/pem.go
new file mode 100644
index 0000000000..1d44657541
--- /dev/null
+++ b/crypto/util/pem.go
@@ -0,0 +1,103 @@
+/*
+ * 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 util
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+)
+
+// PemToPublicKey converts a PEM encoded public key to a crypto.PublicKey
+func PemToPublicKey(pub []byte) (crypto.PublicKey, error) {
+
+ block, _ := pem.Decode(pub)
+ if block == nil {
+ return nil, ErrWrongPublicKey
+ }
+
+ switch block.Type {
+ case "PUBLIC KEY":
+ return x509.ParsePKIXPublicKey(block.Bytes)
+ case "RSA PUBLIC KEY":
+ return x509.ParsePKCS1PublicKey(block.Bytes)
+ default:
+ return nil, ErrWrongPublicKey
+ }
+}
+
+// PublicKeyToPem converts an public key to PEM encoding
+func PublicKeyToPem(pub crypto.PublicKey) (string, error) {
+ pubASN1, err := x509.MarshalPKIXPublicKey(pub)
+
+ if err != nil {
+ return "", err
+ }
+
+ pubBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PUBLIC KEY",
+ Bytes: pubASN1,
+ })
+
+ return string(pubBytes), err
+}
+
+// PrivateKeyToPem converts an public key to PEM encoding
+func PrivateKeyToPem(pub crypto.PrivateKey) (string, error) {
+ pubASN1, err := x509.MarshalPKCS8PrivateKey(pub)
+
+ if err != nil {
+ return "", err
+ }
+
+ pubBytes := pem.EncodeToMemory(&pem.Block{
+ Type: "PRIVATE KEY",
+ Bytes: pubASN1,
+ })
+
+ return string(pubBytes), err
+}
+
+// PemToPrivateKey converts a PEM encoded private key to a Signer interface. It supports EC, RSA and PKIX PEM encoded strings
+func PemToPrivateKey(bytes []byte) (signer crypto.Signer, err error) {
+ block, _ := pem.Decode(bytes)
+ if block == nil {
+ err = ErrWrongPrivateKey
+ return
+ }
+
+ switch block.Type {
+ case "RSA PRIVATE KEY":
+ signer, err = x509.ParsePKCS1PrivateKey(block.Bytes)
+ case "EC PRIVATE KEY":
+ signer, err = x509.ParseECPrivateKey(block.Bytes)
+ case "PRIVATE KEY":
+ var key interface{}
+ key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
+ switch key.(type) {
+ case *rsa.PrivateKey:
+ signer = key.(*rsa.PrivateKey)
+ case *ecdsa.PrivateKey:
+ signer = key.(*ecdsa.PrivateKey)
+ case ed25519.PrivateKey:
+ signer = key.(ed25519.PrivateKey)
+ }
+ }
+ return
+}
diff --git a/crypto/util/pem_test.go b/crypto/util/pem_test.go
new file mode 100644
index 0000000000..e592f8f1a9
--- /dev/null
+++ b/crypto/util/pem_test.go
@@ -0,0 +1,122 @@
+/*
+ * Nuts crypto
+ * Copyright (C) 2019. 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 util
+
+import (
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "encoding/pem"
+ "io/ioutil"
+ "testing"
+
+ "github.com/nuts-foundation/nuts-node/crypto/test"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCrypto_PublicKeyToPem(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ key := test.GenerateRSAKey()
+ result, err := PublicKeyToPem(&key.PublicKey)
+ if !assert.NoError(t, err) {
+ return
+ }
+ assert.NotNil(t, result)
+ assert.Contains(t, result, "-----BEGIN PUBLIC KEY-----")
+ assert.Contains(t, result, "-----END PUBLIC KEY-----")
+ decoded, rest := pem.Decode([]byte(result))
+ assert.Len(t, rest, 0)
+ assert.NotNil(t, decoded)
+ })
+ t.Run("wrong public key gives error", func(t *testing.T) {
+ _, err := PublicKeyToPem(&rsa.PublicKey{})
+ assert.Error(t, err)
+ })
+}
+
+func TestCrypto_pemToPublicKey(t *testing.T) {
+ t.Run("wrong PEM block gives error", func(t *testing.T) {
+ _, err := PemToPublicKey([]byte{})
+
+ if !assert.Error(t, err) {
+ return
+ }
+
+ assert.Equal(t, ErrWrongPublicKey, err)
+ })
+
+ t.Run("converts public key", func(t *testing.T) {
+ pem := "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEny33KMxU+mtPxSBMIztm69lehhNo\nCQD632dFAYSzDGh2LqemmYx9EKFzuzvCqbw87BD3spzbakjj5R315qV0gw==\n-----END PUBLIC KEY-----"
+
+ pk, err := PemToPublicKey([]byte(pem))
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ assert.IsType(t, &ecdsa.PublicKey{}, pk)
+ })
+
+ t.Run("converts RSA public key", func(t *testing.T) {
+ pem := "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBAMXC7V5p/LRULNuRWNBBcizjCrMPIV57LjNG6RCpkJsFtTfw5Ra+aGFJ\nmoEjlSrOsJ1aRO2krR4UTCijOrv1JNFjCvv81urSK9xSUAXzQcPdogf051ZDt1Ct\nEv4ETZQkXDMibzlbmgXq1V+oib4FXDCk0Emu6SAfOGmov/V9eShNAgMBAAE=\n-----END RSA PUBLIC KEY-----"
+
+ pk, err := PemToPublicKey([]byte(pem))
+ if !assert.NoError(t, err) {
+ return
+ }
+
+ assert.IsType(t, &rsa.PublicKey{}, pk)
+ })
+}
+
+func TestPemToSigner(t *testing.T) {
+ t.Run("Convert ED25519 key", func(t *testing.T) {
+ pem, _ := ioutil.ReadFile("../test/ed25519.sk")
+ signer, err := PemToPrivateKey(pem)
+ assert.NoError(t, err)
+ assert.NotNil(t, signer)
+ })
+
+ t.Run("Convert EC key", func(t *testing.T) {
+ pem, _ := ioutil.ReadFile("../test/ec.sk")
+ signer, err := PemToPrivateKey(pem)
+ assert.NoError(t, err)
+ assert.NotNil(t, signer)
+ })
+
+ t.Run("Convert RSA key", func(t *testing.T) {
+ pem, _ := ioutil.ReadFile("../test/rsa.sk")
+ signer, err := PemToPrivateKey(pem)
+ assert.NoError(t, err)
+ assert.NotNil(t, signer)
+ })
+
+ t.Run("Convert PKIX key", func(t *testing.T) {
+ pem, _ := ioutil.ReadFile("../test/sk.pem")
+ signer, err := PemToPrivateKey(pem)
+ assert.NoError(t, err)
+ assert.NotNil(t, signer)
+ })
+
+ t.Run("Convert garbage", func(t *testing.T) {
+ _, err := PemToPrivateKey([]byte{})
+ if assert.Error(t, err) {
+ assert.Equal(t, ErrWrongPrivateKey, err)
+ }
+ })
+}
diff --git a/docs/_static/crypto/v1.yaml b/docs/_static/crypto/v1.yaml
new file mode 100644
index 0000000000..71c07f73ec
--- /dev/null
+++ b/docs/_static/crypto/v1.yaml
@@ -0,0 +1,88 @@
+openapi: "3.0.0"
+info:
+ title: Crypto
+ description: API specification for crypto services available within nuts node
+ version: 1.0.0
+ license:
+ name: GPLv3
+paths:
+ /internal/crypto/v1/public_key/{kid}:
+ get:
+ summary: "get the public key for a given kid. It returns the key in PEM or JWK format. This depends on the accept header used (text/plain vs application/json)"
+ operationId: publicKey
+ tags:
+ - crypto
+ parameters:
+ - name: kid
+ in: path
+ schema:
+ type: string
+ description: "key identifier in did form"
+ example: "did:nuts:e3cacd5c2d931295a64f6c3bb3f6ea58c3a9b253b990e32c5abce43c2f94c564#_TKzHv2jFIyvdTGF1Dsgwngfdg3SH6TpDv0Ta1aOEkw"
+ required: true
+ responses:
+ '200':
+ description: "OK response, body holds public key in PEM/JWK format"
+ content:
+ text/plain:
+ schema:
+ $ref: '#/components/schemas/PublicKey'
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JWK'
+ '404':
+ description: "not found"
+ content:
+ text/plain:
+ example:
+ "key not found"
+
+ /internal/crypto/v1/sign_jwt:
+ post:
+ summary: "sign a JWT payload with the private key of the given kid"
+ operationId: signJwt
+ tags:
+ - crypto
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SignJwtRequest'
+ responses:
+ '200':
+ description: "OK response, body holds JWT"
+ content:
+ text/plain:
+ schema:
+ example: "aa==.bb==.cc=="
+ '400':
+ description: "incorrect data"
+ content:
+ text/plain:
+ example:
+ "unknown kid"
+components:
+ schemas:
+ SignJwtRequest:
+ required:
+ - claims
+ - kid
+ properties:
+ kid:
+ type: string
+ claims:
+ type: object
+ PublicKey:
+ type: string
+ description: "PEM encoded public key"
+ example: "-----BEGIN PUBLIC KEY----- .... -----END PUBLIC KEY-----"
+ JWK:
+ type: object
+ description: as described by https://tools.ietf.org/html/rfc7517. Modelled as object so libraries can parse the tokens themselves.
+ example: { "kty": "EC",
+ "crv": "P-256",
+ "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
+ "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
+ "kid": "Public key used in JWS spec Appendix A.3 example"
+ }
diff --git a/docs/pages/development.rst b/docs/pages/development.rst
index 655c726602..2bdbdc5fc8 100644
--- a/docs/pages/development.rst
+++ b/docs/pages/development.rst
@@ -28,7 +28,7 @@ The server and client API is generated from the open-api spec:
.. code-block:: shell
- oapi-codegen -generate types,server,client -package api docs/_static/example.yaml > api/generated.go
+ oapi-codegen -generate types,server,client -package v1 docs/_static/crypto/v1.yaml > api/crypto/v1/generated.go
Generating Mocks
****************
@@ -37,7 +37,7 @@ These mocks are used by other modules
.. code-block:: shell
- mockgen -destination=mock/mock_example.go -package=mock -source=example.go
+ mockgen -destination=crypto/mock/mock_crypto.go -package=mock -source=crypto/interface.go KeyStore
README
******
diff --git a/go.mod b/go.mod
index 9b044c0002..08d9de0b13 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,13 @@ module github.com/nuts-foundation/nuts-node
go 1.15
require (
+ github.com/deepmap/oapi-codegen v1.4.2
+ github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/golang/mock v1.4.4
github.com/labstack/echo/v4 v4.1.17
+ github.com/lestrrat-go/jwx v1.0.7
+ github.com/magiconair/properties v1.8.4
+ github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.7.1
github.com/sirupsen/logrus v1.7.0
github.com/spf13/cobra v1.0.0
diff --git a/go.sum b/go.sum
index 5ecbfd7902..e890b9b161 100644
--- a/go.sum
+++ b/go.sum
@@ -43,16 +43,21 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deepmap/oapi-codegen v1.4.2 h1:boVMuW+o0sOnEDB8oWRobBm1BrD5d9bUtIQVcjwMsSk=
+github.com/deepmap/oapi-codegen v1.4.2/go.mod h1:1jY0YDxfBF3tXk1u3sARJMSUJa9wV0UrVT6o+2mr/zQ=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/getkin/kin-openapi v0.26.0/go.mod h1:WGRs2ZMM1Q8LR1QBEwUxC6RJEfaBcD0s+pcEVXFuAjw=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -80,6 +85,7 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -142,20 +148,38 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/echo/v4 v4.1.17 h1:PQIBaRplyRy3OjwILGkPg89JRtH2x5bssi59G2EL3fo=
github.com/labstack/echo/v4 v4.1.17/go.mod h1:Tn2yRQL/UclUalpb5rPdXDevbkJ+lp/2svdyFBg6CHQ=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
+github.com/lestrrat-go/backoff/v2 v2.0.3 h1:2ABaTa5ifB1L90aoRMjaPa97p0WzzVe93Vggv8oZftw=
+github.com/lestrrat-go/backoff/v2 v2.0.3/go.mod h1:mU93bMXuG27/Y5erI5E9weqavpTX5qiVFZI4uXAX0xk=
+github.com/lestrrat-go/httpcc v0.0.0-20210101035852-e7e8fea419e3 h1:e52qvXxpJPV/Kb2ovtuYgcRFjNmf9ntcn8BPIbpRM4k=
+github.com/lestrrat-go/httpcc v0.0.0-20210101035852-e7e8fea419e3/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
+github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911 h1:FvnrqecqX4zT0wOIbYK1gNgTm0677INEWiFY8UEYggY=
+github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
+github.com/lestrrat-go/jwx v1.0.7 h1:wmd6LGb9mYPesObp7oipuZPE1aYYJ1VeUin8LTVNy24=
+github.com/lestrrat-go/jwx v1.0.7/go.mod h1:6XJ5sxHF5U116AxYxeHfTnfsZRMgmeKY214zwZDdvho=
+github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35 h1:lea8Wt+1ePkVrI2/WD+NgQT5r/XsLAzxeqtyFLcEs10=
+github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/lestrrat-go/pdebug/v3 v3.0.0-20210111091911-ec4f5c88c087 h1:T5Wh8C/p5nWoGuEUBQj+daEXkj1CScB9GshvvsBJhpg=
+github.com/lestrrat-go/pdebug/v3 v3.0.0-20210111091911-ec4f5c88c087/go.mod h1:za+m+Ve24yCxTEhR59N7UlnJomWwCiIqbJRmKeiADU4=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.4 h1:8KGKTcQQGm0Kv7vEbKFErAoAOFyyacLStRtQSeYtvkY=
+github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
+github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
@@ -180,7 +204,10 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -242,6 +269,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
@@ -251,10 +279,12 @@ github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGr
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
+github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
@@ -266,9 +296,14 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191112222119-e1110fd1c708/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201217014255-9d1352758620 h1:3wPMTskHO3+O6jqTEXyFcsnuxMQOqYSaHsDxcbUXpqA=
+golang.org/x/crypto v0.0.0-20201217014255-9d1352758620/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -287,6 +322,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -303,6 +339,8 @@ golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -330,7 +368,9 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -338,6 +378,7 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8=
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -362,7 +403,11 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200417140056-c07e33ef3290/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
@@ -409,6 +454,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/test/io/io_test.go b/test/io/io_test.go
index 6dca9e99bd..8e4ee7ac44 100644
--- a/test/io/io_test.go
+++ b/test/io/io_test.go
@@ -11,4 +11,4 @@ func Test_normalizeTestName(t *testing.T) {
assert.Equal(t, "Test_normalizeTestName_level_1_____3_level_2__", normalizeTestName(t))
})
})
-}
\ No newline at end of file
+}