diff --git a/cmd/server/flags.go b/cmd/server/flags.go index a7ece4b22..29e4b7ac0 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -169,12 +169,6 @@ var flags = []cli.Flag{ Name: "config-service-endpoint", Usage: "url used for calling configuration service endpoint", }, - &cli.StringFlag{ - EnvVars: []string{"WOODPECKER_CONFIG_SERVICE_SECRET"}, - Name: "config-service-secret", - Usage: "secret to sign requests send to configuration service", - FilePath: os.Getenv("WOODPECKER_CONFIG_SERVICE_SECRET_FILE"), - }, &cli.StringFlag{ EnvVars: []string{"WOODPECKER_DATABASE_DRIVER"}, Name: "driver", diff --git a/cmd/server/server.go b/cmd/server/server.go index 396b1a3a0..77ca3e7a9 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -42,7 +42,7 @@ import ( woodpeckerGrpcServer "github.com/woodpecker-ci/woodpecker/server/grpc" "github.com/woodpecker-ci/woodpecker/server/logging" "github.com/woodpecker-ci/woodpecker/server/model" - "github.com/woodpecker-ci/woodpecker/server/plugins/configuration" + "github.com/woodpecker-ci/woodpecker/server/plugins/config" "github.com/woodpecker-ci/woodpecker/server/pubsub" "github.com/woodpecker-ci/woodpecker/server/remote" "github.com/woodpecker-ci/woodpecker/server/router" @@ -267,13 +267,10 @@ func setupEvilGlobals(c *cli.Context, v store.Store, r remote.Remote) { server.Config.Services.Secrets = setupSecretService(c, v) server.Config.Services.Environ = setupEnvironService(c, v) + server.Config.Services.SignaturePrivateKey, server.Config.Services.SignaturePublicKey = setupSignatureKeys(v) + if endpoint := c.String("config-service-endpoint"); endpoint != "" { - secret := c.String("config-service-secret") - if secret == "" { - log.Error().Msg("could not configure configuration service, missing secret") - } else { - server.Config.Services.ConfigService = configuration.NewAPI(endpoint, secret) - } + server.Config.Services.ConfigService = config.NewHTTP(endpoint, server.Config.Services.SignaturePrivateKey) } // authentication diff --git a/cmd/server/setup.go b/cmd/server/setup.go index e021501a4..88add92a8 100644 --- a/cmd/server/setup.go +++ b/cmd/server/setup.go @@ -16,6 +16,10 @@ package main import ( "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" "fmt" "net/url" "os" @@ -352,3 +356,35 @@ func setupMetrics(g *errgroup.Group, _store store.Store) { } }) } + +// generate or load key pair to sign webhooks requests (i.e. used for extensions) +func setupSignatureKeys(_store store.Store) (crypto.PrivateKey, crypto.PublicKey) { + privKeyID := "signature-private-key" + + privKey, err := _store.ServerConfigGet(privKeyID) + if err != nil && err == datastore.RecordNotExist { + _, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + log.Fatal().Err(err).Msgf("Failed to generate private key") + return nil, nil + } + err = _store.ServerConfigSet(privKeyID, hex.EncodeToString(privKey)) + if err != nil { + log.Fatal().Err(err).Msgf("Failed to generate private key") + return nil, nil + } + log.Info().Msg("Created private key") + return privKey, privKey.Public() + } else if err != nil { + log.Fatal().Err(err).Msgf("Failed to load private key") + return nil, nil + } else { + privKeyStr, err := hex.DecodeString(privKey) + if err != nil { + log.Fatal().Err(err).Msgf("Failed to decode private key") + return nil, nil + } + privKey := ed25519.PrivateKey(privKeyStr) + return privKey, privKey.Public() + } +} diff --git a/docs/docs/30-administration/10-server-config.md b/docs/docs/30-administration/10-server-config.md index 87abbf8e6..c3cde2d3e 100644 --- a/docs/docs/30-administration/10-server-config.md +++ b/docs/docs/30-administration/10-server-config.md @@ -356,16 +356,6 @@ Example: `WOODPECKER_LIMIT_CPU_SET=1,2` Specify a configuration service endpoint, see [Configuration Extension](/docs/administration/external-configuration-api) -### `WOODPECKER_CONFIG_SERVICE_SECRET` -> Default: `` - -Specify a signing secret for the configuration service endpoint, see [Configuration Extension](/docs/administration/external-configuration-api) - -### `WOODPECKER_CONFIG_SERVICE_SECRET_FILE` -> Default: `` - -Read the value for `WOODPECKER_CONFIG_SERVICE_SECRET` from the specified filepath - --- ### `WOODPECKER_GITHUB_...` diff --git a/docs/docs/30-administration/100-external-configuration-api.md b/docs/docs/30-administration/100-external-configuration-api.md index eaced44fd..62a839038 100644 --- a/docs/docs/30-administration/100-external-configuration-api.md +++ b/docs/docs/30-administration/100-external-configuration-api.md @@ -3,7 +3,7 @@ To provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP api which can be enabled to call an external config service. Before the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP api sending the current repository, build information and all current config files retrieved from the repository. The external api can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration. -Every request sent by Woodpecker is signed using a http-signature using the provided secret from `WOODPECKER_CONFIG_SERVICE_SECRET`. This way the external api can verify the authenticity request from the Woodpecker instance. +Every request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`. A simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service) @@ -13,8 +13,6 @@ A simplistic example configuration service can be found here: [https://github.co # Server # ... WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig -WOODPECKER_CONFIG_SERVICE_SECRET=mysecretsigningkey - ``` ### Example request made by Woodpecker diff --git a/docs/docs/91-migrations.md b/docs/docs/91-migrations.md index 27b891fdd..f18cd51d7 100644 --- a/docs/docs/91-migrations.md +++ b/docs/docs/91-migrations.md @@ -4,6 +4,7 @@ Some versions need some changes to the server configuration or the pipeline conf ## 1.0.0 +- The signature used to verify extensions calls (like those used for the [config-extension](/docs/administration/external-configuration-api)) done by the Woodpecker server switched from using a shared-secret HMac to an ed25519 key-pair. Read more about it at the [config-extensions](/docs/administration/external-configuration-api) documentation. - Refactored support of old agent filter labels and expression. Learn how to use the new [filter](/docs/usage/pipeline-syntax#labels). - Renamed step environment variable `CI_SYSTEM_ARCH` to `CI_SYSTEM_PLATFORM`. Same applies for the cli exec variable. diff --git a/go.mod b/go.mod index db1f77328..5330e7957 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.16 require ( code.gitea.io/sdk/gitea v0.15.1-0.20220501190934-319a978c6c71 - github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e github.com/Microsoft/go-winio v0.5.1 // indirect github.com/bmatcuk/doublestar/v4 v4.0.2 github.com/containerd/containerd v1.5.9 // indirect @@ -19,6 +18,7 @@ require ( github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 github.com/franela/goblin v0.0.0-20211003143422-0a4f594942bf github.com/gin-gonic/gin v1.7.7 + github.com/go-ap/httpsig v0.0.0-20210714162115-62a09257db51 github.com/go-playground/validator/v10 v10.10.1 // indirect github.com/go-sql-driver/mysql v1.6.0 github.com/goccy/go-json v0.9.7 // indirect diff --git a/go.sum b/go.sum index f9c3fa0fa..cead04a14 100644 --- a/go.sum +++ b/go.sum @@ -60,8 +60,6 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE= -github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e h1:rl2Aq4ZODqTDkeSqQBy+fzpZPamacO1Srp8zq7jf2Sc= -github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e/go.mod h1:Xa6lInWHNQnuWoF0YPSsx+INFA9qk7/7pTjwb3PInkY= github.com/Antonboom/errname v0.1.5 h1:IM+A/gz0pDhKmlt5KSNTVAvfLMb+65RxavBXpRtCUEg= github.com/Antonboom/errname v0.1.5/go.mod h1:DugbBstvPFQbv/5uLcRRzfrNqKE9tVdVCqWCLp6Cifo= github.com/Antonboom/nilnil v0.1.0 h1:DLDavmg0a6G/F4Lt9t7Enrbgb3Oph6LnDE6YVsmTt74= @@ -448,6 +446,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= +github.com/go-ap/httpsig v0.0.0-20210714162115-62a09257db51 h1:cytjZGyqtAu9JspfDt9ThCJ2KKCT/kPGDPCKWIZv8dw= +github.com/go-ap/httpsig v0.0.0-20210714162115-62a09257db51/go.mod h1:+4SUDMvPlRMUPW5PlMTbxj3U5a4fWasBIbakUw7Kp6c= github.com/go-critic/go-critic v0.6.2 h1:L5SDut1N4ZfsWZY0sH4DCrsHLHnhuuWak2wa165t9gs= github.com/go-critic/go-critic v0.6.2/go.mod h1:td1s27kfmLpe5G/DPjlnFI7o1UCzePptwU7Az0V5iCM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= diff --git a/server/api/build.go b/server/api/build.go index 2f2037185..212a5f695 100644 --- a/server/api/build.go +++ b/server/api/build.go @@ -482,7 +482,7 @@ func PostBuild(c *gin.Context) { currentFileMeta[i] = &remote.FileMeta{Name: cfg.Name, Data: cfg.Data} } - newConfig, useOld, err := server.Config.Services.ConfigService.FetchExternalConfig(c, repo, build, currentFileMeta) + newConfig, useOld, err := server.Config.Services.ConfigService.FetchConfig(c, repo, build, currentFileMeta) if err != nil { msg := fmt.Sprintf("On fetching external build config: %s", err) c.String(http.StatusBadRequest, msg) diff --git a/server/api/signature_public_key.go b/server/api/signature_public_key.go new file mode 100644 index 000000000..c09c36fe5 --- /dev/null +++ b/server/api/signature_public_key.go @@ -0,0 +1,41 @@ +// Copyright 2021 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "crypto/x509" + "encoding/pem" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "github.com/woodpecker-ci/woodpecker/server" +) + +func GetSignaturePublicKey(c *gin.Context) { + b, err := x509.MarshalPKIXPublicKey(server.Config.Services.SignaturePublicKey) + if err != nil { + log.Error().Err(err).Msg("can't marshal public key") + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + block := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: b, + } + + c.String(200, "%s", pem.EncodeToMemory(block)) +} diff --git a/server/config.go b/server/config.go index b08fa7ac2..e675c06d9 100644 --- a/server/config.go +++ b/server/config.go @@ -18,11 +18,12 @@ package server import ( + "crypto" "time" "github.com/woodpecker-ci/woodpecker/server/logging" "github.com/woodpecker-ci/woodpecker/server/model" - "github.com/woodpecker-ci/woodpecker/server/plugins/configuration" + "github.com/woodpecker-ci/woodpecker/server/plugins/config" "github.com/woodpecker-ci/woodpecker/server/pubsub" "github.com/woodpecker-ci/woodpecker/server/queue" "github.com/woodpecker-ci/woodpecker/server/remote" @@ -30,14 +31,16 @@ import ( var Config = struct { Services struct { - Pubsub pubsub.Publisher - Queue queue.Queue - Logs logging.Log - Secrets model.SecretService - Registries model.RegistryService - Environ model.EnvironService - Remote remote.Remote - ConfigService configuration.ConfigService + Pubsub pubsub.Publisher + Queue queue.Queue + Logs logging.Log + Secrets model.SecretService + Registries model.RegistryService + Environ model.EnvironService + Remote remote.Remote + ConfigService config.Extension + SignaturePrivateKey crypto.PrivateKey + SignaturePublicKey crypto.PublicKey } Storage struct { // Users model.UserStore diff --git a/server/model/server_config.go b/server/model/server_config.go new file mode 100644 index 000000000..145c449eb --- /dev/null +++ b/server/model/server_config.go @@ -0,0 +1,27 @@ +// Copyright 2022 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +// ServerConfigStore persists key-value pairs for storing server configurations. +type ServerConfigStore interface { + ServerConfigGet(key string) (string, error) + ServerConfigSet(key int64, value string) error +} + +// ServerConfig represents a key-value pair for storing server configurations. +type ServerConfig struct { + Key string `json:"key" xorm:"pk"` + Value string `json:"value" xorm:""` +} diff --git a/server/plugins/config/extension.go b/server/plugins/config/extension.go new file mode 100644 index 000000000..c01322885 --- /dev/null +++ b/server/plugins/config/extension.go @@ -0,0 +1,13 @@ +package config + +import ( + "context" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/remote" +) + +type Extension interface { + IsConfigured() bool + FetchConfig(ctx context.Context, repo *model.Repo, build *model.Build, currentFileMeta []*remote.FileMeta) (configData []*remote.FileMeta, useOld bool, err error) +} diff --git a/server/plugins/config/http.go b/server/plugins/config/http.go new file mode 100644 index 000000000..d831e2ffc --- /dev/null +++ b/server/plugins/config/http.go @@ -0,0 +1,66 @@ +package config + +import ( + "context" + "crypto" + "fmt" + + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/plugins/utils" + "github.com/woodpecker-ci/woodpecker/server/remote" +) + +type http struct { + endpoint string + privateKey crypto.PrivateKey +} + +// Same as remote.FileMeta but with json tags and string data +type config struct { + Name string `json:"name"` + Data string `json:"data"` +} + +type requestStructure struct { + Repo *model.Repo `json:"repo"` + Build *model.Build `json:"build"` + Configuration []*config `json:"configs"` +} + +type responseStructure struct { + Configs []config `json:"configs"` +} + +func NewHTTP(endpoint string, privateKey crypto.PrivateKey) Extension { + return &http{endpoint, privateKey} +} + +func (cp *http) IsConfigured() bool { + return cp.endpoint != "" +} + +func (cp *http) FetchConfig(ctx context.Context, repo *model.Repo, build *model.Build, currentFileMeta []*remote.FileMeta) (configData []*remote.FileMeta, useOld bool, err error) { + currentConfigs := make([]*config, len(currentFileMeta)) + for i, pipe := range currentFileMeta { + currentConfigs[i] = &config{Name: pipe.Name, Data: string(pipe.Data)} + } + + response := new(responseStructure) + body := requestStructure{Repo: repo, Build: build, Configuration: currentConfigs} + status, err := utils.Send(ctx, "POST", cp.endpoint, cp.privateKey, body, response) + if err != nil && status != 204 { + return nil, false, fmt.Errorf("Failed to fetch config via http (%d) %w", status, err) + } + + var newFileMeta []*remote.FileMeta + if status != 200 { + newFileMeta = make([]*remote.FileMeta, 0) + } else { + newFileMeta = make([]*remote.FileMeta, len(response.Configs)) + for i, pipe := range response.Configs { + newFileMeta[i] = &remote.FileMeta{Name: pipe.Name, Data: []byte(pipe.Data)} + } + } + + return newFileMeta, status == 204, nil +} diff --git a/server/plugins/configuration/configuration.go b/server/plugins/configuration/configuration.go deleted file mode 100644 index bb766f4fe..000000000 --- a/server/plugins/configuration/configuration.go +++ /dev/null @@ -1,127 +0,0 @@ -package configuration - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "io/ioutil" - "net/http" - "net/url" - - "github.com/99designs/httpsignatures-go" - - "github.com/woodpecker-ci/woodpecker/server/model" - "github.com/woodpecker-ci/woodpecker/server/remote" -) - -type ConfigService struct { - endpoint string - secret string -} - -// Same as remote.FileMeta but with json tags and string data -type config struct { - Name string `json:"name"` - Data string `json:"data"` -} - -type requestStructure struct { - Repo *model.Repo `json:"repo"` - Build *model.Build `json:"build"` - Configuration []*config `json:"configs"` -} - -type responseStructure struct { - Configs []config `json:"configs"` -} - -func NewAPI(endpoint, secret string) ConfigService { - return ConfigService{endpoint: endpoint, secret: secret} -} - -func (cp *ConfigService) IsConfigured() bool { - return cp.endpoint != "" -} - -func (cp *ConfigService) FetchExternalConfig(ctx context.Context, repo *model.Repo, build *model.Build, currentFileMeta []*remote.FileMeta) (configData []*remote.FileMeta, useOld bool, err error) { - currentConfigs := make([]*config, len(currentFileMeta)) - for i, pipe := range currentFileMeta { - currentConfigs[i] = &config{Name: pipe.Name, Data: string(pipe.Data)} - } - - response, status, err := sendRequest(ctx, "POST", cp.endpoint, cp.secret, requestStructure{Repo: repo, Build: build, Configuration: currentConfigs}) - if err != nil { - return nil, false, fmt.Errorf("Failed to fetch config via http (%d) %w", status, err) - } - - var newFileMeta []*remote.FileMeta - if response != nil { - newFileMeta = make([]*remote.FileMeta, len(response.Configs)) - for i, pipe := range response.Configs { - newFileMeta[i] = &remote.FileMeta{Name: pipe.Name, Data: []byte(pipe.Data)} - } - } else { - newFileMeta = make([]*remote.FileMeta, 0) - } - - return newFileMeta, status == 204, nil -} - -func sendRequest(ctx context.Context, method, path, signkey string, in interface{}) (response *responseStructure, statuscode int, err error) { - uri, err := url.Parse(path) - if err != nil { - return nil, 0, err - } - - // if we are posting or putting data, we need to - // write it to the body of the request. - var buf io.ReadWriter - if in != nil { - buf = new(bytes.Buffer) - jsonerr := json.NewEncoder(buf).Encode(in) - if jsonerr != nil { - return nil, 0, jsonerr - } - } - - // creates a new http request to bitbucket. - req, err := http.NewRequestWithContext(ctx, method, uri.String(), buf) - if err != nil { - return nil, 0, err - } - if in != nil { - req.Header.Set("Content-Type", "application/json") - } - - // Sign using the 'Signature' header - err = httpsignatures.DefaultSha256Signer.SignRequest("hmac-key", signkey, req) - if err != nil { - return nil, 0, err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 && resp.StatusCode != 204 { - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, resp.StatusCode, err - } - - return nil, resp.StatusCode, fmt.Errorf("Response: %s", string(body)) - } - - if resp.StatusCode == 204 { - return nil, resp.StatusCode, nil - } - - // if no other errors parse and return the json response. - decodedResponse := new(responseStructure) - err = json.NewDecoder(resp.Body).Decode(decodedResponse) - return decodedResponse, resp.StatusCode, err -} diff --git a/server/plugins/internal/http.go b/server/plugins/internal/http.go deleted file mode 100644 index afb2cede3..000000000 --- a/server/plugins/internal/http.go +++ /dev/null @@ -1,83 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "encoding/json" - "io" - "io/ioutil" - "net/http" - "net/url" -) - -// Send makes an http request to the given endpoint, writing the input -// to the request body and unmarshaling the output from the response body. -func Send(ctx context.Context, method, path string, in, out interface{}) error { - uri, err := url.Parse(path) - if err != nil { - return err - } - - // if we are posting or putting data, we need to - // write it to the body of the request. - var buf io.ReadWriter - if in != nil { - buf = new(bytes.Buffer) - jsonerr := json.NewEncoder(buf).Encode(in) - if jsonerr != nil { - return jsonerr - } - } - - // creates a new http request to bitbucket. - req, err := http.NewRequestWithContext(ctx, method, uri.String(), buf) - if err != nil { - return err - } - if in != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - // if an error is encountered, parse and return the - // error response. - if resp.StatusCode > http.StatusPartialContent { - out, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - return &Error{ - code: resp.StatusCode, - text: string(out), - } - } - - // if a json response is expected, parse and return - // the json response. - if out != nil { - return json.NewDecoder(resp.Body).Decode(out) - } - - return nil -} - -// Error represents a http error. -type Error struct { - code int - text string -} - -// Code returns the http error code. -func (e *Error) Code() int { - return e.code -} - -// Error returns the error message in string format. -func (e *Error) Error() string { - return e.text -} diff --git a/server/plugins/utils/http.go b/server/plugins/utils/http.go new file mode 100644 index 000000000..c089218e2 --- /dev/null +++ b/server/plugins/utils/http.go @@ -0,0 +1,75 @@ +package utils + +import ( + "bytes" + "context" + "crypto" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + + "github.com/go-ap/httpsig" +) + +// Send makes an http request to the given endpoint, writing the input +// to the request body and un-marshaling the output from the response body. +func Send(ctx context.Context, method, path string, privateKey crypto.PrivateKey, in, out interface{}) (int, error) { + uri, err := url.Parse(path) + if err != nil { + return 0, err + } + + // if we are posting or putting data, we need to write it to the body of the request. + var buf io.ReadWriter + if in != nil { + buf = new(bytes.Buffer) + jsonerr := json.NewEncoder(buf).Encode(in) + if jsonerr != nil { + return 0, jsonerr + } + } + + // creates a new http request to the endpoint. + req, err := http.NewRequestWithContext(ctx, method, uri.String(), buf) + if err != nil { + return 0, err + } + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + err = SignHTTPRequest(privateKey, req) + if err != nil { + return 0, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, err + } + + return resp.StatusCode, fmt.Errorf("Response: %s", string(body)) + } + + // if no other errors parse and return the json response. + err = json.NewDecoder(resp.Body).Decode(out) + return resp.StatusCode, err +} + +func SignHTTPRequest(privateKey crypto.PrivateKey, req *http.Request) error { + pubKeyID := "woodpecker-ci-plugins" + + signer := httpsig.NewEd25519Signer(pubKeyID, privateKey, nil) + + return signer.Sign(req) +} diff --git a/server/plugins/utils/http_test.go b/server/plugins/utils/http_test.go new file mode 100644 index 000000000..9cfe4eb37 --- /dev/null +++ b/server/plugins/utils/http_test.go @@ -0,0 +1,65 @@ +package utils_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-ap/httpsig" + "github.com/woodpecker-ci/woodpecker/server/plugins/utils" +) + +func TestSign(t *testing.T) { + pubKeyID := "woodpecker-ci-plugins" + + pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + + body := []byte("{\"foo\":\"bar\"}") + + req, err := http.NewRequest("GET", "http://example.com", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Content-Type", "application/json") + + err = utils.SignHTTPRequest(privEd25519Key, req) + if err != nil { + t.Fatal(err) + } + + VerifyHandler := func(w http.ResponseWriter, r *http.Request) { + keystore := httpsig.NewMemoryKeyStore() + keystore.SetKey(pubKeyID, pubEd25519Key) + + verifier := httpsig.NewVerifier(keystore) + verifier.SetRequiredHeaders([]string{"(request-target)", "date"}) + + keyID, err := verifier.Verify(r) + if err != nil { + t.Fatal(err) + } + + if keyID != pubKeyID { + t.Fatalf("expected key ID %q, got %q", pubKeyID, keyID) + } + + w.WriteHeader(http.StatusOK) + } + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(VerifyHandler) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } +} diff --git a/server/router/api.go b/server/router/api.go index a41500cfe..467059de2 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -145,6 +145,8 @@ func apiRoutes(e *gin.Engine) { logLevel.POST("", api.SetLogLevel) } + e.GET("/api/signature/public-key", session.MustUser(), api.GetSignaturePublicKey) + // TODO: remove /hook in favor of /api/hook e.POST("/hook", api.PostHook) e.POST("/api/hook", api.PostHook) diff --git a/server/shared/configFetcher.go b/server/shared/configFetcher.go index 6c0ca9dc2..433b578c7 100644 --- a/server/shared/configFetcher.go +++ b/server/shared/configFetcher.go @@ -8,7 +8,7 @@ import ( "time" "github.com/rs/zerolog/log" - "github.com/woodpecker-ci/woodpecker/server/plugins/configuration" + "github.com/woodpecker-ci/woodpecker/server/plugins/config" "github.com/woodpecker-ci/woodpecker/server/model" "github.com/woodpecker-ci/woodpecker/server/remote" @@ -19,20 +19,20 @@ type ConfigFetcher interface { } type configFetcher struct { - remote remote.Remote - user *model.User - repo *model.Repo - build *model.Build - configService configuration.ConfigService + remote remote.Remote + user *model.User + repo *model.Repo + build *model.Build + configExtension config.Extension } -func NewConfigFetcher(remote remote.Remote, configurationService configuration.ConfigService, user *model.User, repo *model.Repo, build *model.Build) ConfigFetcher { +func NewConfigFetcher(remote remote.Remote, configExtension config.Extension, user *model.User, repo *model.Repo, build *model.Build) ConfigFetcher { return &configFetcher{ - remote: remote, - user: user, - repo: repo, - build: build, - configService: configurationService, + remote: remote, + user: user, + repo: repo, + build: build, + configExtension: configExtension, } } @@ -53,12 +53,12 @@ func (cf *configFetcher) Fetch(ctx context.Context) (files []*remote.FileMeta, e continue } - if cf.configService.IsConfigured() { + if cf.configExtension.IsConfigured() { fetchCtx, cancel := context.WithTimeout(ctx, configFetchTimeout) defer cancel() // ok here as we only try http fetching once, returning on fail and success log.Trace().Msgf("ConfigFetch[%s]: getting config from external http service", cf.repo.FullName) - newConfigs, useOld, err := cf.configService.FetchExternalConfig(fetchCtx, cf.repo, cf.build, files) + newConfigs, useOld, err := cf.configExtension.FetchConfig(fetchCtx, cf.repo, cf.build, files) if err != nil { log.Error().Msg("Got error " + err.Error()) return nil, fmt.Errorf("On Fetching config via http : %s", err) diff --git a/server/shared/configFetcher_test.go b/server/shared/configFetcher_test.go index 0778621c8..96eb6ad1e 100644 --- a/server/shared/configFetcher_test.go +++ b/server/shared/configFetcher_test.go @@ -2,21 +2,22 @@ package shared_test import ( "context" + "crypto/ed25519" + "crypto/rand" "encoding/json" "fmt" "io/ioutil" - "log" "net/http" "net/http/httptest" "path/filepath" "testing" - "github.com/99designs/httpsignatures-go" + "github.com/go-ap/httpsig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/woodpecker-ci/woodpecker/server/model" - "github.com/woodpecker-ci/woodpecker/server/plugins/configuration" + "github.com/woodpecker-ci/woodpecker/server/plugins/config" "github.com/woodpecker-ci/woodpecker/server/remote" "github.com/woodpecker-ci/woodpecker/server/remote/mocks" "github.com/woodpecker-ci/woodpecker/server/shared" @@ -244,7 +245,7 @@ func TestFetch(t *testing.T) { configFetcher := shared.NewConfigFetcher( r, - configuration.NewAPI("", ""), + config.NewHTTP("", ""), &model.User{Token: "xxx"}, repo, &model.Build{Commit: "89ab7b2d6bfb347144ac7c557e638ab402848fee"}, @@ -341,17 +342,29 @@ func TestFetchFromConfigService(t *testing.T) { }, } - httpSigSecret := "wykf9frJbGXwSHcJ7AQF4tlfXUo0Tkixh57WPEXMyWVgkxIsAarYa2Hb8UTwPpbqO0N3NueKwjv4DVhPgvQjGur3LuCbiGHbBoaL1X5gZ9oyxD2lBHndoNxifDyNH7tNPw3Lh5lX2MSrWP1yuqHp8Sgm7fX8pLTjaKKFgFIKlODd" + pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal("can't generate ed25519 key pair") + } fixtureHandler := func(w http.ResponseWriter, r *http.Request) { // check signature - signature, err := httpsignatures.FromRequest(r) + pubKeyID := "woodpecker-ci-plugins" + + keystore := httpsig.NewMemoryKeyStore() + keystore.SetKey(pubKeyID, pubEd25519Key) + + verifier := httpsig.NewVerifier(keystore) + verifier.SetRequiredHeaders([]string{"(request-target)", "date"}) + + keyID, err := verifier.Verify(r) if err != nil { - http.Error(w, "Invalid or Missing Signature", http.StatusBadRequest) + http.Error(w, "Invalid signature", http.StatusBadRequest) return } - if !signature.IsValid(httpSigSecret, r) { - http.Error(w, "Invalid Signature", http.StatusBadRequest) + + if keyID != pubKeyID { + http.Error(w, "Used wrong key", http.StatusBadRequest) return } @@ -369,7 +382,6 @@ func TestFetchFromConfigService(t *testing.T) { var req incoming body, err := ioutil.ReadAll(r.Body) if err != nil { - log.Printf("Error reading body: %v", err) http.Error(w, "can't read body", http.StatusBadRequest) return } @@ -409,7 +421,7 @@ func TestFetchFromConfigService(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(fixtureHandler)) defer ts.Close() - configAPI := configuration.NewAPI(ts.URL, httpSigSecret) + configAPI := config.NewHTTP(ts.URL, privEd25519Key) for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { diff --git a/server/store/datastore/migration/migration.go b/server/store/datastore/migration/migration.go index c252e9565..7c165c433 100644 --- a/server/store/datastore/migration/migration.go +++ b/server/store/datastore/migration/migration.go @@ -49,6 +49,7 @@ var allBeans = []interface{}{ new(model.Secret), new(model.Task), new(model.User), + new(model.ServerConfig), } type migrations struct { diff --git a/server/store/datastore/server_config.go b/server/store/datastore/server_config.go new file mode 100644 index 000000000..44908a061 --- /dev/null +++ b/server/store/datastore/server_config.go @@ -0,0 +1,36 @@ +package datastore + +import "github.com/woodpecker-ci/woodpecker/server/model" + +func (s storage) ServerConfigGet(key string) (string, error) { + config := &model.ServerConfig{ + Key: key, + } + + err := wrapGet(s.engine.Get(config)) + if err != nil { + return "", err + } + + return config.Value, nil +} + +func (s storage) ServerConfigSet(key, value string) error { + config := &model.ServerConfig{ + Key: key, + Value: value, + } + + count, err := s.engine.Count(config) + if err != nil { + return err + } + + if count == 0 { + _, err := s.engine.Insert(config) + return err + } + + _, err = s.engine.Where("key = ?", config.Key).AllCols().Update(config) + return err +} diff --git a/server/store/datastore/server_config_test.go b/server/store/datastore/server_config_test.go new file mode 100644 index 000000000..18feb59d7 --- /dev/null +++ b/server/store/datastore/server_config_test.go @@ -0,0 +1,32 @@ +package datastore + +import ( + "testing" + + "github.com/woodpecker-ci/woodpecker/server/model" +) + +func TestServerConfigGetSet(t *testing.T) { + store, closer := newTestStore(t, new(model.ServerConfig)) + defer closer() + + serverConfig := &model.ServerConfig{ + Key: "test", + Value: "wonderland", + } + if err := store.ServerConfigSet(serverConfig.Key, serverConfig.Value); err != nil { + t.Errorf("Unexpected error: insert secret: %s", err) + return + } + + value, err := store.ServerConfigGet(serverConfig.Key) + if err != nil { + t.Errorf("Unexpected error: delete secret: %s", err) + return + } + + if value != serverConfig.Value { + t.Errorf("Want server-config value %s, got %s", serverConfig.Value, value) + return + } +} diff --git a/server/store/store.go b/server/store/store.go index d0ceb4604..2279983bf 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -145,6 +145,10 @@ type Store interface { TaskInsert(*model.Task) error TaskDelete(string) error + // ServerConfig + ServerConfigGet(string) (string, error) + ServerConfigSet(string, string) error + // Store operations Ping() error Close() error diff --git a/vendor/github.com/99designs/httpsignatures-go/.travis.yml b/vendor/github.com/99designs/httpsignatures-go/.travis.yml deleted file mode 100644 index 11c017fac..000000000 --- a/vendor/github.com/99designs/httpsignatures-go/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: go - -go: - - 1.2 - - 1.3 - - 1.4 - - 1.5 - - 1.6 - - 1.7 - - 1.8 - -install: - - go get github.com/stretchr/testify/assert diff --git a/vendor/github.com/99designs/httpsignatures-go/LICENSE b/vendor/github.com/99designs/httpsignatures-go/LICENSE deleted file mode 100644 index 135f520b5..000000000 --- a/vendor/github.com/99designs/httpsignatures-go/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ - -The MIT License (MIT) - -Copyright (c) 2016 99designs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/vendor/github.com/99designs/httpsignatures-go/README.md b/vendor/github.com/99designs/httpsignatures-go/README.md deleted file mode 100644 index b88ad2e24..000000000 --- a/vendor/github.com/99designs/httpsignatures-go/README.md +++ /dev/null @@ -1,9 +0,0 @@ -httpsignatures-go -================= -[![GoDoc](https://godoc.org/github.com/99designs/httpsignatures-go?status.svg)](https://godoc.org/github.com/99designs/httpsignatures-go) -[![Build Status](https://travis-ci.org/99designs/httpsignatures-go.svg)](https://travis-ci.org/99designs/httpsignatures-go) - - -Golang library for the [http-signatures spec](https://tools.ietf.org/html/draft-cavage-http-signatures). - -See https://godoc.org/github.com/99designs/httpsignatures-go for documentation and examples \ No newline at end of file diff --git a/vendor/github.com/99designs/httpsignatures-go/algorithm.go b/vendor/github.com/99designs/httpsignatures-go/algorithm.go deleted file mode 100644 index 1a9b260a1..000000000 --- a/vendor/github.com/99designs/httpsignatures-go/algorithm.go +++ /dev/null @@ -1,31 +0,0 @@ -package httpsignatures - -import ( - "crypto/sha1" - "crypto/sha256" - "errors" - "hash" -) - -var ( - AlgorithmHmacSha256 = &Algorithm{"hmac-sha256", sha256.New} - AlgorithmHmacSha1 = &Algorithm{"hmac-sha1", sha1.New} - - ErrorUnknownAlgorithm = errors.New("Unknown Algorithm") -) - -type Algorithm struct { - name string - hash func() hash.Hash -} - -func algorithmFromString(name string) (*Algorithm, error) { - switch name { - case AlgorithmHmacSha1.name: - return AlgorithmHmacSha1, nil - case AlgorithmHmacSha256.name: - return AlgorithmHmacSha256, nil - } - - return nil, ErrorUnknownAlgorithm -} diff --git a/vendor/github.com/99designs/httpsignatures-go/signature.go b/vendor/github.com/99designs/httpsignatures-go/signature.go deleted file mode 100644 index 3a27cd9e1..000000000 --- a/vendor/github.com/99designs/httpsignatures-go/signature.go +++ /dev/null @@ -1,202 +0,0 @@ -// httpsignatures is a golang implementation of the http-signatures spec -// found at https://tools.ietf.org/html/draft-cavage-http-signatures -package httpsignatures - -import ( - "crypto/hmac" - "crypto/subtle" - "encoding/base64" - "errors" - "fmt" - "net/http" - "regexp" - "strings" -) - -const ( - headerSignature = "Signature" - headerAuthorization = "Authorization" - - RequestTarget = "(request-target)" - - authScheme = "Signature " -) - -var ( - ErrorNoSignatureHeader = errors.New("No Signature header found in request") - - signatureRegex = regexp.MustCompile(`(\w+)="([^"]*)"`) -) - -// Signature is the hashed key + headers, either from a request or a signer -type Signature struct { - KeyID string - Algorithm *Algorithm - Headers HeaderList - Signature string -} - -// FromRequest creates a new Signature from the Request -// both Signature and Authorization http headers are supported. -func FromRequest(r *http.Request) (*Signature, error) { - if s, ok := r.Header[headerSignature]; ok { - return FromString(s[0]) - } - if a, ok := r.Header[headerAuthorization]; ok { - return FromString(strings.TrimPrefix(a[0], authScheme)) - } - return nil, ErrorNoSignatureHeader -} - -// FromString creates a new Signature from its encoded form, -// eg `keyId="a",algorithm="b",headers="c",signature="d"` -func FromString(in string) (*Signature, error) { - var res Signature = Signature{} - var key string - var value string - - for _, m := range signatureRegex.FindAllStringSubmatch(in, -1) { - key = m[1] - value = m[2] - - if key == "keyId" { - res.KeyID = value - } else if key == "algorithm" { - alg, err := algorithmFromString(value) - if err != nil { - return nil, err - } - res.Algorithm = alg - } else if key == "headers" { - res.Headers = headerListFromString(value) - } else if key == "signature" { - res.Signature = value - } else { - return nil, errors.New(fmt.Sprintf("Unexpected key in signature '%s'", key)) - } - } - - if len(res.Signature) == 0 { - return nil, errors.New("Missing signature") - } - - if len(res.KeyID) == 0 { - return nil, errors.New("Missing keyId") - } - - if res.Algorithm == nil { - return nil, errors.New("Missing algorithm") - } - - return &res, nil -} - -// String returns the encoded form of the Signature -func (s Signature) String() string { - str := fmt.Sprintf( - `keyId="%s",algorithm="%s",signature="%s"`, - s.KeyID, - s.Algorithm.name, - s.Signature, - ) - - if len(s.Headers) > 0 { - str += fmt.Sprintf(`,headers="%s"`, s.Headers.String()) - } - - return str -} - -func (s Signature) calculateSignature(key string, r *http.Request) (string, error) { - hash := hmac.New(s.Algorithm.hash, []byte(key)) - - signingString, err := s.Headers.signingString(r) - if err != nil { - return "", err - } - - hash.Write([]byte(signingString)) - - return base64.StdEncoding.EncodeToString(hash.Sum(nil)), nil -} - -// Sign this signature using the given key -func (s *Signature) sign(key string, r *http.Request) error { - sig, err := s.calculateSignature(key, r) - if err != nil { - return err - } - - s.Signature = sig - return nil -} - -// IsValid validates this signature for the given key -func (s Signature) IsValid(key string, r *http.Request) bool { - if !s.Headers.hasDate() { - return false - } - - sig, err := s.calculateSignature(key, r) - if err != nil { - return false - } - - return subtle.ConstantTimeCompare([]byte(s.Signature), []byte(sig)) == 1 -} - -type HeaderList []string - -func headerListFromString(list string) HeaderList { - return strings.Split(strings.ToLower(string(list)), " ") -} - -func (h HeaderList) String() string { - return strings.ToLower(strings.Join(h, " ")) -} - -func (h HeaderList) hasDate() bool { - for _, header := range h { - if header == "date" { - return true - } - } - - return false -} - -func (h HeaderList) signingString(req *http.Request) (string, error) { - lines := []string{} - - for _, header := range h { - if header == RequestTarget { - lines = append(lines, requestTargetLine(req)) - } else { - line, err := headerLine(req, header) - if err != nil { - return "", err - } - lines = append(lines, line) - } - } - - return strings.Join(lines, "\n"), nil -} - -func requestTargetLine(req *http.Request) string { - var url string = "" - if req.URL != nil { - url = req.URL.RequestURI() - } - - return fmt.Sprintf("%s: %s %s", RequestTarget, strings.ToLower(req.Method), url) -} - -func headerLine(req *http.Request, header string) (string, error) { - - if value := req.Header.Get(header); value != "" { - return fmt.Sprintf("%s: %s", header, value), nil - } - - return "", errors.New(fmt.Sprintf("Missing required header '%s'", header)) -} diff --git a/vendor/github.com/99designs/httpsignatures-go/signer.go b/vendor/github.com/99designs/httpsignatures-go/signer.go deleted file mode 100644 index dce5f5132..000000000 --- a/vendor/github.com/99designs/httpsignatures-go/signer.go +++ /dev/null @@ -1,79 +0,0 @@ -package httpsignatures - -import ( - "net/http" - "strings" - "time" -) - -// Signer is used to create a signature for a given request. -type Signer struct { - algorithm *Algorithm - headers HeaderList -} - -var ( - // DefaultSha1Signer will sign requests with the url and date using the SHA1 algorithm. - // Users are encouraged to create their own signer with the headers they require. - DefaultSha1Signer = NewSigner(AlgorithmHmacSha1, RequestTarget, "date") - - // DefaultSha256Signer will sign requests with the url and date using the SHA256 algorithm. - // Users are encouraged to create their own signer with the headers they require. - DefaultSha256Signer = NewSigner(AlgorithmHmacSha256, RequestTarget, "date") -) - -func NewSigner(algorithm *Algorithm, headers ...string) *Signer { - hl := HeaderList{} - - for _, header := range headers { - hl = append(hl, strings.ToLower(header)) - } - - return &Signer{ - algorithm: algorithm, - headers: hl, - } -} - -// SignRequest adds a http signature to the Signature: HTTP Header -func (s Signer) SignRequest(id, key string, r *http.Request) error { - sig, err := s.buildSignature(id, key, r) - if err != nil { - return err - } - - r.Header.Add(headerSignature, sig.String()) - - return nil -} - -// AuthRequest adds a http signature to the Authorization: HTTP Header -func (s Signer) AuthRequest(id, key string, r *http.Request) error { - sig, err := s.buildSignature(id, key, r) - if err != nil { - return err - } - - r.Header.Add(headerAuthorization, authScheme+sig.String()) - - return nil -} - -func (s Signer) buildSignature(id, key string, r *http.Request) (*Signature, error) { - if r.Header.Get("date") == "" { - r.Header.Set("date", time.Now().Format(time.RFC1123)) - } - - sig := &Signature{ - KeyID: id, - Algorithm: s.algorithm, - Headers: s.headers, - } - - err := sig.sign(key, r) - if err != nil { - return nil, err - } - - return sig, nil -} diff --git a/vendor/github.com/go-ap/httpsig/LICENSE b/vendor/github.com/go-ap/httpsig/LICENSE new file mode 100644 index 000000000..8405e89a0 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/LICENSE @@ -0,0 +1,191 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/vendor/github.com/go-ap/httpsig/Makefile b/vendor/github.com/go-ap/httpsig/Makefile new file mode 100644 index 000000000..186891aa9 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/Makefile @@ -0,0 +1,54 @@ +GOPATH := $(shell go env GOPATH) + +all: build + +getdeps: + @echo "Installing golint" && go get -u golang.org/x/lint/golint + @echo "Installing gocyclo" && go get -u github.com/fzipp/gocyclo + @echo "Installing deadcode" && go get -u github.com/remyoudompheng/go-misc/deadcode + @echo "Installing misspell" && go get -u github.com/client9/misspell/cmd/misspell + @echo "Installing ineffassign" && go get -u github.com/gordonklaus/ineffassign + +verifiers: vet fmt lint cyclo spelling static deadcode + +vet: + @echo "Running $@" + @go vet -atomic -bool -copylocks -nilfunc -printf -rangeloops -unreachable -unsafeptr -unusedresult ./... + +fmt: + @echo "Running $@" + @gofmt -d . + +lint: + @echo "Running $@" + @${GOPATH}/bin/golint -set_exit_status $(shell go list ./...) + +ineffassign: + @echo "Running $@" + @${GOPATH}/bin/ineffassign . + +cyclo: + @echo "Running $@" + @${GOPATH}/bin/gocyclo -over 100 . + +deadcode: + @echo "Running $@" + @${GOPATH}/bin/deadcode -test $(shell go list ./...) || true + +spelling: + @echo "Running $@" + @${GOPATH}/bin/misspell -i monitord -error `find .` + +static: + @echo "Running $@" + go run honnef.co/go/tools/cmd/staticcheck -- ./... + +check: test +test: verifiers build + go test -v ./... + +testrace: verifiers build + go test -v -race ./... + +build: + go build -v ./... diff --git a/vendor/github.com/go-ap/httpsig/README.md b/vendor/github.com/go-ap/httpsig/README.md new file mode 100644 index 000000000..bbd5e2e4e --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/README.md @@ -0,0 +1,138 @@ +# HTTPSIG for Go + +This library implements HTTP request signature generation and verification based on +the RFC draft specification https://tools.ietf.org/html/draft-cavage-http-signatures-12. + +The library strives be compatible with the popular python library of the same +name: https://github.com/ahknight/httpsig + +## Installing + +``` +go get gopkg.in/spacemonkeygo/httpsig.v0 +``` + +## Signing Requests + +Signing requests is done by constructing a new `Signer`. The key id, key, +algorithm, and what headers to sign are required. + +For example to construct a `Signer` with key id `"foo"`, using an RSA private +key, for the rsa-sha256 algorithm, with the default header set, you can do: + +```go +var key *rsa.PrivateKey = ... +signer := httpsig.NewSigner("foo", key, httpsig.RSASHA256, nil) +``` + +There are helper functions for specific algorithms that are less verbose and +provide more type safety (the key paramater need not be of type `interface{}` +because the type required for the algorithm is known). + +```go +var key *rsa.PrivateKey = ... +signer := httpsig.NewRSASHA256Signer("foo", key, nil) +``` + +By default, if no headers are passed to `NewSigner` (or the helpers), the +`(request-target)` pseudo-header and `Date` header are signed. + +To sign requests, call the `Sign()` method. The method signs the request and +adds an `Authorization` header containing the signature parameters. + +```go +err = signer.Sign(req) +if err != nil { + ... +} +fmt.Println("AUTHORIZATION:", req.Header.Get('Authorization')) + +... +AUTHORIZATION: Signature: keyId="foo",algorithm="sha-256",signature="..." +``` + +## Verifying Requests + +Verifying requests is done by constructing a new `Verifier`. The verifier +requires a KeyGetter implementation to look up keys based on `keyId`'s +retrieved from signature parameters. + +```go +var getter httpsig.KeyGetter = .... +verifier := httpsig.NewVerifier(getter) +``` + +A request can be verified by calling the `Verify()` method: + +```go +err = verifier.Verify(req) +``` + +By default, the verifier only requires the `Date` header to be included +in the signature. The set of required headers be changed using the +`SetRequiredHeaders()` method to enforce stricter requirements. + +```go +verifier.SetRequiredHeaders([]string{"(request-target)", "host", "date"}) +``` + +Requests that don't include the full set of required headers in the `headers` +signature parameter (either implicitly or explicitly) will fail verification. + +**Note that required headers are simply a specification for which headers must +be included in the signature, and does not enforce header presence in requests. +It is up to callers to validate header contents (or the lack thereof).** + +A simple in-memory key store is provided by the library and can be constructed +with the `NewMemoryKeyStore()` function. Keys can be added using the SetKey +method: +```go +keystore := NewMemoryKeyStore() + +var rsa_key *rsa.PublicKey = ... +keystore.SetKey("foo", rsa_key) + +var hmac_key []byte = ... +keystore.SetKey("foo", hmac_key) +``` + +## Handler + +A convenience function is provided that wraps an `http.Handler` and verifies +incoming request signatures before passing them down to the wrapped handler. + +If requires a verifier and optionally a realm (for constructing the +`WWW-Authenticate` header). + +```go +var handler http.Handler = ... +var verifier *httpsig.Verifier = ... +wrapped := httpsig.RequireSignature(handler, verifier, "example.com") +``` + +If signature validation fails, a `401` is returned along with a +`WWW-Authenticate` header containing a `Signature` challenge with optional +`realm` and `headers` parameters. + +## Supported algorithms + +- rsa-sha1 (using PKCS1v15) +- rsa-sha256 (using PKCS1v15) +- hmac-sha256 +- ed25519 + +### License + +Copyright (C) 2017 Space Monkey, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/github.com/go-ap/httpsig/common.go b/vendor/github.com/go-ap/httpsig/common.go new file mode 100644 index 000000000..06f4406cc --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/common.go @@ -0,0 +1,140 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import ( + ed "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var ( + // Rand is a hookable reader used as a random byte source. + Rand io.Reader = rand.Reader +) + +// requestPath returns the :path pseudo header according to the HTTP/2 spec. +func requestPath(req *http.Request) string { + path := req.URL.Path + if path == "" { + path = "/" + } + if req.URL.RawQuery != "" { + path += "?" + req.URL.RawQuery + } + return path +} + +// BuildSignatureString constructs a signature string following section 2.3 +func BuildSignatureString(req *http.Request, headers []string, created, expires time.Time) (string, error) { + if len(headers) == 0 { + headers = []string{"(created)"} + } + + values := make([]string, 0, len(headers)) + for _, h := range headers { + + switch h { + case "(request-target)": + values = append(values, fmt.Sprintf("%s: %s %s", + h, strings.ToLower(req.Method), requestPath(req))) + case "(created)": + values = append(values, fmt.Sprintf("%s: %d", h, created.Unix())) + case "(expires)": + values = append(values, fmt.Sprintf("%s: %d", h, expires.Unix())) + case "host": + values = append(values, fmt.Sprintf("%s: %s", h, req.Host)) + case "date": + if req.Header.Get(h) == "" { + req.Header.Set(h, time.Now().UTC().Format(http.TimeFormat)) + } + values = append(values, fmt.Sprintf("%s: %s", h, req.Header.Get(h))) + default: + vs, found := req.Header[http.CanonicalHeaderKey(h)] + if !found { + return "", fmt.Errorf("expected %s to exists", h) + } + for _, v := range vs { + values = append(values, fmt.Sprintf("%s: %s", h, strings.TrimSpace(v))) + } + } + } + return strings.Join(values, "\n"), nil +} + +// BuildSignatureData is a convenience wrapper around BuildSignatureString that +// returns []byte instead of a string. +func BuildSignatureData(req *http.Request, headers []string, created, expires time.Time) ([]byte, error) { + s, err := BuildSignatureString(req, headers, created, expires) + return []byte(s), err +} + +func toRSAPrivateKey(key interface{}) *rsa.PrivateKey { + switch k := key.(type) { + case *rsa.PrivateKey: + return k + default: + return nil + } +} + +func toRSAPublicKey(key interface{}) *rsa.PublicKey { + switch k := key.(type) { + case *rsa.PublicKey: + return k + case *rsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} + +func toHMACKey(key interface{}) []byte { + switch k := key.(type) { + case []byte: + return k + default: + return nil + } +} + +func toEd25519PrivateKey(key interface{}) ed.PrivateKey { + switch k := key.(type) { + case ed.PrivateKey: + return k + default: + return nil + } +} + +func toEd25519PublicKey(key interface{}) ed.PublicKey { + switch k := key.(type) { + case ed.PublicKey: + return k + case ed.PrivateKey: + return k.Public().(ed.PublicKey) + default: + return nil + } +} + +func unsupportedAlgorithm(a Algorithm) error { + return fmt.Errorf("key does not support algorithm %q", a.Name()) +} diff --git a/vendor/github.com/go-ap/httpsig/ed25519.go b/vendor/github.com/go-ap/httpsig/ed25519.go new file mode 100644 index 000000000..fd0fe6422 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/ed25519.go @@ -0,0 +1,58 @@ +package httpsig + +import ( + ed "crypto/ed25519" + "fmt" +) + +// Ed25519 implements Ed25519 Algorithm +var Ed25519 Algorithm = ed25519{} + +type ed25519 struct{} + +func (ed25519) Name() string { + return "ed25519" +} + +func (a ed25519) Sign(key interface{}, data []byte) ([]byte, error) { + k := toEd25519PrivateKey(key) + if k == nil { + return nil, unsupportedAlgorithm(a) + } + return Ed25519Sign(k, data) +} + +func (a ed25519) Verify(key interface{}, data, sig []byte) error { + k := toHMACKey(key) + if k == nil { + return unsupportedAlgorithm(a) + } + return Ed25519Verify(k, data, sig) +} + +// Ed25519Verify reports whether sig is a valid signature of message by publicKey. +func Ed25519Verify(key interface{}, message, sig []byte) error { + k, ok := key.(ed.PublicKey) + if !ok { + return fmt.Errorf("key must be an instance of crypto/ed25519.PublicKey") + } + if len(k) != ed.PublicKeySize { + return fmt.Errorf("public key has the wrong size") + } + if !ed.Verify(k, message, sig) { + return fmt.Errorf("signature verification failed") + } + return nil +} + +// Ed25519Sign signs the message with privateKey and returns a signature. +func Ed25519Sign(key interface{}, message []byte) ([]byte, error) { + k, ok := key.(ed.PrivateKey) + if !ok { + return nil, fmt.Errorf("key must be an instance of crypto/ed25519.PrivateKey") + } + if len(k) != ed.PrivateKeySize { + return nil, fmt.Errorf("private key has the wrong size") + } + return ed.Sign(k, message), nil +} diff --git a/vendor/github.com/go-ap/httpsig/go.mod b/vendor/github.com/go-ap/httpsig/go.mod new file mode 100644 index 000000000..20ed7889d --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/go.mod @@ -0,0 +1,9 @@ +module github.com/go-ap/httpsig + +go 1.14 + +require ( + github.com/kr/pretty v0.1.0 // indirect + github.com/stretchr/testify v1.5.1 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect +) diff --git a/vendor/github.com/go-ap/httpsig/go.sum b/vendor/github.com/go-ap/httpsig/go.sum new file mode 100644 index 000000000..cfe395974 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/vendor/github.com/go-ap/httpsig/handler.go b/vendor/github.com/go-ap/httpsig/handler.go new file mode 100644 index 000000000..016962814 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/handler.go @@ -0,0 +1,76 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// ctxKeyIDType is the type used to retrieve the KeyId parametes extracted from the HTTP headers +// and set into the request.Context during call of verifier.Verify +type ctxKeyIDType struct{} + +var ctxKeyIDKey = &ctxKeyIDType{} + +// WithKeyID retrieves the KeyId parameter from the requests +func WithKeyID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, ctxKeyIDKey, id) +} + +// KeyIDFromContext returns the request ID from the context. +// A zero ID is returned if there are no identifers in the +// current context. +func KeyIDFromContext(ctx context.Context) string { + v := ctx.Value(ctxKeyIDKey) + if v == nil { + return "" + } + return v.(string) +} + +// RequireSignature is a http middleware that ensure the incoming request have +// the required signature using verifier v +func RequireSignature(h http.Handler, v *Verifier, realm string) ( + out http.Handler) { + + var challengeParams []string + if realm != "" { + challengeParams = append(challengeParams, + fmt.Sprintf("realm=%q", realm)) + } + if headers := v.RequiredHeaders(); len(headers) > 0 { + challengeParams = append(challengeParams, + fmt.Sprintf("headers=%q", strings.Join(headers, " "))) + } + + challenge := "Signature" + if len(challengeParams) > 0 { + challenge += fmt.Sprintf(" %s", strings.Join(challengeParams, ", ")) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + keyID, err := v.Verify(req) + if err != nil { + w.Header()["WWW-Authenticate"] = []string{challenge} + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintln(w, err.Error()) + return + } + h.ServeHTTP(w, req.WithContext(WithKeyID(req.Context(), keyID))) + }) +} diff --git a/vendor/github.com/go-ap/httpsig/hmac.go b/vendor/github.com/go-ap/httpsig/hmac.go new file mode 100644 index 000000000..e7c385c2d --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/hmac.go @@ -0,0 +1,68 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import ( + "crypto" + "crypto/hmac" + "errors" +) + +// HMACSHA256 implements keyed HMAC over SHA256 digests +var HMACSHA256 Algorithm = hmacSha256{} + +type hmacSha256 struct{} + +func (hmacSha256) Name() string { + return "hmac-sha256" +} + +func (a hmacSha256) Sign(key interface{}, data []byte) ([]byte, error) { + k := toHMACKey(key) + if k == nil { + return nil, unsupportedAlgorithm(a) + } + return HMACSign(k, crypto.SHA256, data) +} + +func (a hmacSha256) Verify(key interface{}, data, sig []byte) error { + k := toHMACKey(key) + if k == nil { + return unsupportedAlgorithm(a) + } + return HMACVerify(k, crypto.SHA256, data, sig) +} + +// HMACSign signs a digest of the data hashed using the provided hash and key. +func HMACSign(key []byte, hash crypto.Hash, data []byte) ([]byte, error) { + h := hmac.New(hash.New, key) + if _, err := h.Write(data); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// HMACVerify verifies a signed digest of the data hashed using the provided +// hash and key. +func HMACVerify(key []byte, hash crypto.Hash, data, sig []byte) error { + actualSig, err := HMACSign(key, hash, data) + if err != nil { + return err + } + if !hmac.Equal(actualSig, sig) { + return errors.New("hmac signature mismatch") + } + return nil +} diff --git a/vendor/github.com/go-ap/httpsig/httpsig.go b/vendor/github.com/go-ap/httpsig/httpsig.go new file mode 100644 index 000000000..394617f21 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/httpsig.go @@ -0,0 +1,44 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +// Algorithm provides methods used to sign/verify signatures. +type Algorithm interface { + Name() string + Sign(key interface{}, data []byte) (sig []byte, err error) + Verify(key interface{}, data, sig []byte) error +} + +// KeyGetter is an interface used by the verifier to retrieve a key stored +// by key id. +// +// The following types are supported for the specified algorithms: +// []byte - HMAC signatures +// *rsa.PublicKey - RSA signatures +// *rsa.PrivateKey - RSA signatures +// +// Other types will treated as if no key was returned. +type KeyGetter interface { + GetKey(id string) (interface{}, error) +} + +// KeyGetterFunc is a convenience type for implementing a KeyGetter with a +// regular function +type KeyGetterFunc func(id string) (interface{}, error) + +// GetKey calls fn(id) +func (fn KeyGetterFunc) GetKey(id string) (interface{}, error) { + return fn(id) +} diff --git a/vendor/github.com/go-ap/httpsig/keystore.go b/vendor/github.com/go-ap/httpsig/keystore.go new file mode 100644 index 000000000..82d894154 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/keystore.go @@ -0,0 +1,44 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import "fmt" + +// MemoryKeyStore is a simple in memory key store that implement the +// KeyGetter interface +type MemoryKeyStore struct { + keys map[string]interface{} +} + +// NewMemoryKeyStore creates a new MemoryKeyStore +func NewMemoryKeyStore() *MemoryKeyStore { + return &MemoryKeyStore{ + keys: make(map[string]interface{}), + } +} + +// GetKey implements KeyGetter interface +func (m *MemoryKeyStore) GetKey(id string) (interface{}, error) { + pk, ok := m.keys[id] + if !ok { + return nil, fmt.Errorf("key not found") + } + return pk, nil +} + +// SetKey link id to a key +func (m *MemoryKeyStore) SetKey(id string, key interface{}) { + m.keys[id] = key +} diff --git a/vendor/github.com/go-ap/httpsig/rsa.go b/vendor/github.com/go-ap/httpsig/rsa.go new file mode 100644 index 000000000..1952b56d5 --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/rsa.go @@ -0,0 +1,92 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import ( + "crypto" + "crypto/rsa" +) + +// RSASHA1 implements RSA PKCS1v15 signatures over a SHA1 digest +var RSASHA1 Algorithm = rsaSha1{} + +type rsaSha1 struct{} + +func (rsaSha1) Name() string { + return "rsa-sha1" +} + +func (a rsaSha1) Sign(key interface{}, data []byte) ([]byte, error) { + k := toRSAPrivateKey(key) + if k == nil { + return nil, unsupportedAlgorithm(a) + } + return RSASign(k, crypto.SHA1, data) +} + +func (a rsaSha1) Verify(key interface{}, data, sig []byte) error { + k := toRSAPublicKey(key) + if k == nil { + return unsupportedAlgorithm(a) + } + return RSAVerify(k, crypto.SHA1, data, sig) +} + +// RSASHA256 implements RSA PKCS1v15 signatures over a SHA256 digest +var RSASHA256 Algorithm = rsaSha256{} + +type rsaSha256 struct{} + +func (rsaSha256) Name() string { + return "rsa-sha256" +} + +func (a rsaSha256) Sign(key interface{}, data []byte) ([]byte, error) { + k := toRSAPrivateKey(key) + if k == nil { + return nil, unsupportedAlgorithm(a) + } + return RSASign(k, crypto.SHA256, data) +} + +func (a rsaSha256) Verify(key interface{}, data, sig []byte) error { + k := toRSAPublicKey(key) + if k == nil { + return unsupportedAlgorithm(a) + } + return RSAVerify(k, crypto.SHA256, data, sig) +} + +// RSASign signs a digest of the data hashed using the provided hash +func RSASign(key *rsa.PrivateKey, hash crypto.Hash, data []byte) ( + signature []byte, err error) { + + h := hash.New() + if _, err := h.Write(data); err != nil { + return nil, err + } + return rsa.SignPKCS1v15(Rand, key, hash, h.Sum(nil)) +} + +// RSAVerify verifies a signed digest of the data hashed using the provided hash +func RSAVerify(key *rsa.PublicKey, hash crypto.Hash, data, sig []byte) ( + err error) { + + h := hash.New() + if _, err := h.Write(data); err != nil { + return err + } + return rsa.VerifyPKCS1v15(key, hash, h.Sum(nil), sig) +} diff --git a/vendor/github.com/go-ap/httpsig/sign.go b/vendor/github.com/go-ap/httpsig/sign.go new file mode 100644 index 000000000..aa31c45dc --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/sign.go @@ -0,0 +1,122 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import ( + "crypto" + "crypto/rsa" + "encoding/base64" + "fmt" + "net/http" + "strings" + "time" +) + +// Signer is the type used by HTTP clients to sign their request +type Signer struct { + id string + key interface{} + algo Algorithm + headers []string +} + +// NewSigner contructs a signer with the specified key id, key, algorithm, +// and headers to sign. By default, if headers is nil or empty, the +// request-target and date headers will be signed. +func NewSigner(id string, key interface{}, algo Algorithm, headers []string) ( + signer *Signer) { + + s := &Signer{ + id: id, + key: key, + algo: algo, + } + + // copy the headers slice, lowercasing as necessary + if len(headers) == 0 { + headers = []string{"(request-target)", "date"} + } + s.headers = make([]string, 0, len(headers)) + for _, header := range headers { + s.headers = append(s.headers, strings.ToLower(header)) + } + return s +} + +// NewRSASHA1Signer contructs a signer with the specified key id, rsa private +// key and headers to sign. +func NewRSASHA1Signer(id string, key *rsa.PrivateKey, headers []string) (signer *Signer) { + return NewSigner(id, key, RSASHA1, headers) +} + +// NewRSASHA256Signer contructs a signer with the specified key id, rsa private +// key and headers to sign. +func NewRSASHA256Signer(id string, key *rsa.PrivateKey, headers []string) (signer *Signer) { + return NewSigner(id, key, RSASHA256, headers) +} + +// NewHMACSHA256Signer contructs a signer with the specified key id, hmac key, +// and headers to sign. +func NewHMACSHA256Signer(id string, key []byte, headers []string) ( + signer *Signer) { + return NewSigner(id, key, HMACSHA256, headers) +} + +// NewEd25519Signer contructs a signer with the specified key id, Ed25519 key, +// and headers to sign. +func NewEd25519Signer(id string, key crypto.PrivateKey, headers []string) *Signer { + return NewSigner(id, key, Ed25519, headers) +} + +// Sign signs an http request and adds the signature to the authorization header +func (r *Signer) Sign(req *http.Request) error { + now := time.Now() + params, err := signRequest(r.id, r.key, r.algo, r.headers, now, now.Add(time.Minute), req) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Signature "+params) + return nil +} + +// signRequest signs an http request and returns the parameter string. +func signRequest(id string, key interface{}, algo Algorithm, headers []string, created, expires time.Time, req *http.Request) (params string, err error) { + signatureData, err := BuildSignatureData(req, headers, created, expires) + if err != nil { + return "", err + } + + signature, err := algo.Sign(key, signatureData) + if err != nil { + return "", err + } + + // The headers parameter can be omitted if the only header is "Date". The + // receiving end assumes ["date"] if no headers parameter is present. + var headersParam string + if !(len(headers) == 1 && headers[0] == "date") { + headersParam = fmt.Sprintf("headers=%q,", strings.Join(headers, " ")) + } + + return fmt.Sprintf( + "keyId=%q,algorithm=%q,created=\"%d\",expires=\"%d\",%ssignature=%q", + id, + algo.Name(), + created.Unix(), + expires.Unix(), + headersParam, + base64.StdEncoding.EncodeToString(signature)), nil +} diff --git a/vendor/github.com/go-ap/httpsig/verify.go b/vendor/github.com/go-ap/httpsig/verify.go new file mode 100644 index 000000000..7c6ba4f8b --- /dev/null +++ b/vendor/github.com/go-ap/httpsig/verify.go @@ -0,0 +1,249 @@ +// Copyright (C) 2017 Space Monkey, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpsig + +import ( + "crypto" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// Verifier is used by by the HTTP server to verify the incoming HTTP requests +type Verifier struct { + keyGetter KeyGetter + requiredHeaders []string +} + +// NewVerifier creates a new Verifier using kg to get the key +// mapped to the ID received in the requests +func NewVerifier(kg KeyGetter) *Verifier { + v := &Verifier{ + keyGetter: kg, + } + v.SetRequiredHeaders(nil) + return v +} + +// RequiredHeaders returns the required header the client have to include in +// the signature +func (v *Verifier) RequiredHeaders() []string { + return append([]string{}, v.requiredHeaders...) +} + +// SetRequiredHeaders set the list of headers to be included by the client to generate the signature +func (v *Verifier) SetRequiredHeaders(headers []string) { + if len(headers) == 0 { + headers = []string{"date"} + } + requiredHeaders := make([]string, 0, len(headers)) + for _, h := range headers { + requiredHeaders = append(requiredHeaders, strings.ToLower(h)) + } + v.requiredHeaders = requiredHeaders +} + +// Verify parses req and verify the signature using the key returned by +// the keyGetter. It returns the KeyId parameter from he signature header +// and a nil error if the signature verifies, an error otherwise +func (v *Verifier) Verify(req *http.Request) (string, error) { + // retrieve and validate params from the request + params := getParamsFromAuthHeader(req) + if params == nil { + return "", fmt.Errorf("no params present") + } + if params.KeyID == "" { + return "", fmt.Errorf("keyId is required") + } + if params.Algorithm == "" { + return "", fmt.Errorf("algorithm is required") + } + if len(params.Signature) == 0 { + return "", fmt.Errorf("signature is required") + } + if len(params.Headers) == 0 { + params.Headers = []string{"date"} + } + +header_check: + for _, h := range v.requiredHeaders { + for _, header := range params.Headers { + if strings.EqualFold(h, header) { + continue header_check + } + } + return "", fmt.Errorf("missing required header in signature %q", + h) + } + + // calculate signature string for request + sigData, err := BuildSignatureData(req, params.Headers, params.Created, params.Expires) + if err != nil { + return "", err + } + + // look up key based on keyId + key, err := v.keyGetter.GetKey(params.KeyID) + if err != nil { + return "", err + } + // we still leave this sanity check + if key == nil { + return "", fmt.Errorf("no key with id %q", params.KeyID) + } + + switch params.Algorithm { + case "rsa-sha1": + rsaPubkey := toRSAPublicKey(key) + if rsaPubkey == nil { + return "", fmt.Errorf("algorithm %q is not supported by key %q", + params.Algorithm, params.KeyID) + } + return params.KeyID, RSAVerify(rsaPubkey, crypto.SHA1, sigData, params.Signature) + case "rsa-sha256": + rsaPubkey := toRSAPublicKey(key) + if rsaPubkey == nil { + return "", fmt.Errorf("algorithm %q is not supported by key %q", + params.Algorithm, params.KeyID) + } + return params.KeyID, RSAVerify(rsaPubkey, crypto.SHA256, sigData, params.Signature) + case "hmac-sha256": + hmacKey := toHMACKey(key) + if hmacKey == nil { + return "", fmt.Errorf("algorithm %q is not supported by key %q", + params.Algorithm, params.KeyID) + } + return params.KeyID, HMACVerify(hmacKey, crypto.SHA256, sigData, params.Signature) + case "ed25519": + ed25519Key := toEd25519PublicKey(key) + if ed25519Key == nil { + return "", fmt.Errorf("algorithm %q is not supported by key %q", + params.Algorithm, params.KeyID) + } + return params.KeyID, Ed25519Verify(ed25519Key, sigData, params.Signature) + default: + return "", fmt.Errorf("unsupported algorithm %q", params.Algorithm) + } +} + +// paramRE scans out recognized parameter keypairs. accepted values are those +// that are quoted +var paramRE = regexp.MustCompile(`(?U)\s*([a-zA-Z][a-zA-Z0-9_]*)\s*=\s*"(.*)"\s*`) + +// Params holds the field requires to build the signature string +type Params struct { + KeyID string + Algorithm string + Headers []string + Signature []byte + Created time.Time + Expires time.Time +} + +func getParamsFromAuthHeader(req *http.Request) *Params { + return getParams(req, "Authorization", "Signature ") +} + +func getParams(req *http.Request, header, prefix string) *Params { + values := req.Header[http.CanonicalHeaderKey(header)] + // last well-formed parameter wins + for i := len(values) - 1; i >= 0; i-- { + value := values[i] + if prefix != "" { + if trimmed := strings.TrimPrefix(value, prefix); trimmed != value { + value = trimmed + } else { + continue + } + } + + matches := paramRE.FindAllStringSubmatch(value, -1) + if matches == nil { + continue + } + + params := Params{} + // malformed parameters get ignored. + for _, match := range matches { + switch match[1] { + case "keyId": + params.KeyID = match[2] + case "algorithm": + if algorithm, ok := parseAlgorithm(match[2]); ok { + params.Algorithm = algorithm + } + case "headers": + if headers, ok := parseHeaders(match[2]); ok { + params.Headers = headers + } + case "signature": + if signature, ok := parseSignature(match[2]); ok { + params.Signature = signature + } + case "created": + if created, ok := parseTime(match[2]); ok { + params.Created = created + } + case "expires": + if expires, ok := parseTime(match[2]); ok { + params.Expires = expires + } + } + } + return ¶ms + } + + return nil +} + +// parseAlgorithm parses recognized algorithm values +func parseAlgorithm(s string) (algorithm string, ok bool) { + s = strings.TrimSpace(s) + switch s { + case "rsa-sha1", "rsa-sha256", "hmac-sha256", "ed25519": + return s, true + } + return "", false +} + +// parseHeaders parses a space separated list of header values. +func parseHeaders(s string) (headers []string, ok bool) { + for _, header := range strings.Split(s, " ") { + if header != "" { + headers = append(headers, strings.ToLower(header)) + } + } + return headers, true +} + +func parseSignature(s string) (signature []byte, ok bool) { + signature, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, false + } + return signature, true +} + +func parseTime(s string) (t time.Time, ok bool) { + sec, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return t, false + } + return time.Unix(sec, 0), true +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 7da179729..478e94379 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -3,9 +3,6 @@ # code.gitea.io/sdk/gitea v0.15.1-0.20220501190934-319a978c6c71 ## explicit code.gitea.io/sdk/gitea -# github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e -## explicit -github.com/99designs/httpsignatures-go # github.com/Antonboom/errname v0.1.5 github.com/Antonboom/errname/pkg/analyzer # github.com/Antonboom/nilnil v0.1.0 @@ -151,6 +148,9 @@ github.com/gin-gonic/gin/binding github.com/gin-gonic/gin/internal/bytesconv github.com/gin-gonic/gin/internal/json github.com/gin-gonic/gin/render +# github.com/go-ap/httpsig v0.0.0-20210714162115-62a09257db51 +## explicit +github.com/go-ap/httpsig # github.com/go-critic/go-critic v0.6.2 github.com/go-critic/go-critic/checkers github.com/go-critic/go-critic/checkers/internal/astwalk