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 +}