feat: upload certificates and public keys for verifying signatures (#1485)

In order to verify signatures, users could upload their certificates and public keys using these routes:
	-> for public keys:
		/v2/_zot/ext/mgmt?resource=signatures&tool=cosign
	-> for certificates:
		/v2/_zot/ext/mgmt?resource=signatures&tool=notation&truststoreType=ca&truststoreName=name
Then the public keys will be stored under $rootdir/_cosign and the certificates will be stored under
$rootdir/_notation/truststore/x509/$truststoreType/$truststoreName.
Also, for notation case, the "truststores" field of $rootir/_notation/trustpolicy.json file will be
updated with a new entry "$truststoreType:$truststoreName".
Also based on the uploaded files, the information about the signatures validity will be updated
periodically.

Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
This commit is contained in:
Andreea Lupu
2023-07-06 14:57:59 +03:00
committed by GitHub
parent 49e4d93f42
commit 41b05c60dd
19 changed files with 1575 additions and 193 deletions
+233 -15
View File
@@ -4,15 +4,27 @@
package extensions
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/opencontainers/go-digest"
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/api/constants"
zcommon "zotregistry.io/zot/pkg/common"
"zotregistry.io/zot/pkg/log"
"zotregistry.io/zot/pkg/meta/repodb"
"zotregistry.io/zot/pkg/meta/signatures"
"zotregistry.io/zot/pkg/scheduler"
)
const (
ConfigResource = "config"
SignaturesResource = "signatures"
)
type HTPasswd struct {
@@ -70,23 +82,38 @@ type mgmt struct {
log log.Logger
}
// mgmtHandler godoc
// @Summary Get current server configuration
// @Description Get current server configuration
// @Router /v2/_zot/ext/mgmt [get]
// @Accept json
// @Produce json
// @Success 200 {object} extensions.StrippedConfig
// @Failure 500 {string} string "internal server error".
func (mgmt *mgmt) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sanitizedConfig := mgmt.config.Sanitize()
buf, err := zcommon.MarshalThroughStruct(sanitizedConfig, &StrippedConfig{})
if err != nil {
mgmt.log.Error().Err(err).Msg("mgmt: couldn't marshal config response")
w.WriteHeader(http.StatusInternalServerError)
var resource string
if queryHasParams(r.URL.Query(), []string{"resource"}) {
resource = r.URL.Query().Get("resource")
} else {
resource = ConfigResource // default value of "resource" query param
}
switch resource {
case ConfigResource:
if r.Method == http.MethodGet {
mgmt.HandleGetConfig(w, r)
} else {
w.WriteHeader(http.StatusBadRequest)
}
return
case SignaturesResource:
if r.Method == http.MethodPost {
HandleCertificatesAndPublicKeysUploads(w, r) //nolint: contextcheck
} else {
w.WriteHeader(http.StatusBadRequest)
}
return
default:
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = w.Write(buf)
})
}
@@ -96,7 +123,7 @@ func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger)
mgmt := mgmt{config: config, log: log}
allowedMethods := zcommon.AllowedMethods(http.MethodGet)
allowedMethods := zcommon.AllowedMethods(http.MethodGet, http.MethodPost)
mgmtRouter := router.PathPrefix(constants.ExtMgmt).Subrouter()
mgmtRouter.Use(zcommon.ACHeadersHandler(allowedMethods...))
@@ -104,3 +131,194 @@ func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger)
mgmtRouter.Methods(allowedMethods...).Handler(mgmt.handler())
}
}
// mgmtHandler godoc
// @Summary Get current server configuration
// @Description Get current server configuration
// @Router /v2/_zot/ext/mgmt [get]
// @Accept json
// @Produce json
// @Param resource query string false "specify resource" Enums(config)
// @Success 200 {object} extensions.StrippedConfig
// @Failure 500 {string} string "internal server error".
func (mgmt *mgmt) HandleGetConfig(w http.ResponseWriter, r *http.Request) {
sanitizedConfig := mgmt.config.Sanitize()
buf, err := zcommon.MarshalThroughStruct(sanitizedConfig, &StrippedConfig{})
if err != nil {
mgmt.log.Error().Err(err).Msg("mgmt: couldn't marshal config response")
w.WriteHeader(http.StatusInternalServerError)
}
_, _ = w.Write(buf)
}
// mgmtHandler godoc
// @Summary Upload certificates and public keys for verifying signatures
// @Description Upload certificates and public keys for verifying signatures
// @Router /v2/_zot/ext/mgmt [post]
// @Accept octet-stream
// @Produce json
// @Param resource query string true "specify resource" Enums(signatures)
// @Param tool query string true "specify signing tool" Enums(cosign, notation)
// @Param truststoreType query string false "truststore type"
// @Param truststoreName query string false "truststore name"
// @Param requestBody body string true "Public key or Certificate content"
// @Success 200 {string} string "ok"
// @Failure 400 {string} string "bad request".
// @Failure 500 {string} string "internal server error".
func HandleCertificatesAndPublicKeysUploads(response http.ResponseWriter, request *http.Request) {
if !queryHasParams(request.URL.Query(), []string{"tool"}) {
response.WriteHeader(http.StatusBadRequest)
return
}
body, err := io.ReadAll(request.Body)
if err != nil {
response.WriteHeader(http.StatusInternalServerError)
return
}
tool := request.URL.Query().Get("tool")
switch tool {
case signatures.CosignSignature:
err := signatures.UploadPublicKey(body)
if err != nil {
response.WriteHeader(http.StatusInternalServerError)
return
}
case signatures.NotationSignature:
var truststoreType string
if !queryHasParams(request.URL.Query(), []string{"truststoreName"}) {
response.WriteHeader(http.StatusBadRequest)
return
}
if queryHasParams(request.URL.Query(), []string{"truststoreType"}) {
truststoreType = request.URL.Query().Get("truststoreType")
} else {
truststoreType = "ca" // default value of "truststoreType" query param
}
truststoreName := request.URL.Query().Get("truststoreName")
if truststoreType == "" || truststoreName == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
err = signatures.UploadCertificate(body, truststoreType, truststoreName)
if err != nil {
response.WriteHeader(http.StatusInternalServerError)
return
}
default:
response.WriteHeader(http.StatusBadRequest)
return
}
response.WriteHeader(http.StatusOK)
}
func EnablePeriodicSignaturesVerification(config *config.Config, taskScheduler *scheduler.Scheduler,
repoDB repodb.RepoDB, log log.Logger,
) {
if config.Extensions.Search != nil && *config.Extensions.Search.Enable {
ctx := context.Background()
repos, err := repoDB.GetMultipleRepoMeta(ctx, func(repoMeta repodb.RepoMetadata) bool {
return true
}, repodb.PageInput{})
if err != nil {
return
}
generator := &taskGeneratorSigValidity{
repos: repos,
repoDB: repoDB,
repoIndex: -1,
log: log,
}
numberOfHours := 2
interval := time.Duration(numberOfHours) * time.Minute
taskScheduler.SubmitGenerator(generator, interval, scheduler.MediumPriority)
}
}
type taskGeneratorSigValidity struct {
repos []repodb.RepoMetadata
repoDB repodb.RepoDB
repoIndex int
done bool
log log.Logger
}
func (gen *taskGeneratorSigValidity) Next() (scheduler.Task, error) {
gen.repoIndex++
if gen.repoIndex >= len(gen.repos) {
gen.done = true
return nil, nil
}
return NewValidityTask(gen.repoDB, gen.repos[gen.repoIndex], gen.log), nil
}
func (gen *taskGeneratorSigValidity) IsDone() bool {
return gen.done
}
func (gen *taskGeneratorSigValidity) Reset() {
gen.done = false
gen.repoIndex = -1
ctx := context.Background()
repos, err := gen.repoDB.GetMultipleRepoMeta(ctx, func(repoMeta repodb.RepoMetadata) bool {
return true
}, repodb.PageInput{})
if err != nil {
return
}
gen.repos = repos
}
type validityTask struct {
repoDB repodb.RepoDB
repo repodb.RepoMetadata
log log.Logger
}
func NewValidityTask(repoDB repodb.RepoDB, repo repodb.RepoMetadata, log log.Logger) *validityTask {
return &validityTask{repoDB, repo, log}
}
func (validityT *validityTask) DoWork() error {
validityT.log.Info().Msg("updating signatures validity")
for signedManifest, sigs := range validityT.repo.Signatures {
if len(sigs[signatures.CosignSignature]) != 0 || len(sigs[signatures.NotationSignature]) != 0 {
err := validityT.repoDB.UpdateSignaturesValidity(validityT.repo.Name, digest.Digest(signedManifest))
if err != nil {
validityT.log.Info().Msg("error while verifying signatures")
return err
}
}
}
validityT.log.Info().Msg("verifying signatures successfully completed")
return nil
}
@@ -8,6 +8,8 @@ import (
"zotregistry.io/zot/pkg/api/config"
"zotregistry.io/zot/pkg/log"
"zotregistry.io/zot/pkg/meta/repodb"
"zotregistry.io/zot/pkg/scheduler"
)
func IsBuiltWithMGMTExtension() bool {
@@ -18,3 +20,10 @@ func SetupMgmtRoutes(config *config.Config, router *mux.Router, log log.Logger)
log.Warn().Msg("skipping setting up mgmt routes because given zot binary doesn't include this feature," +
"please build a binary that does so")
}
func EnablePeriodicSignaturesVerification(config *config.Config, taskScheduler *scheduler.Scheduler,
repoDB repodb.RepoDB, log log.Logger,
) {
log.Warn().Msg("skipping adding to the scheduler a generator for updating signatures validity because " +
"given binary doesn't include this feature, please build a binary that does so")
}
@@ -0,0 +1,54 @@
//go:build !mgmt
package extensions_test
import (
"os"
"testing"
. "github.com/smartystreets/goconvey/convey"
"zotregistry.io/zot/pkg/api"
"zotregistry.io/zot/pkg/api/config"
extconf "zotregistry.io/zot/pkg/extensions/config"
"zotregistry.io/zot/pkg/test"
)
func TestMgmtExtension(t *testing.T) {
Convey("periodic signature verification is skipped when binary doesn't include mgmt", t, func() {
conf := config.New()
port := test.GetFreePort()
globalDir := t.TempDir()
defaultValue := true
logFile, err := os.CreateTemp(globalDir, "zot-log*.txt")
So(err, ShouldBeNil)
defer os.Remove(logFile.Name())
conf.HTTP.Port = port
conf.Storage.RootDirectory = globalDir
conf.Storage.Commit = true
conf.Extensions = &extconf.ExtensionConfig{}
conf.Extensions.Mgmt = &extconf.MgmtConfig{
BaseConfig: extconf.BaseConfig{
Enable: &defaultValue,
},
}
conf.Log.Level = "warn"
conf.Log.Output = logFile.Name()
ctlr := api.NewController(conf)
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
defer ctlrManager.StopServer()
data, err := os.ReadFile(logFile.Name())
So(err, ShouldBeNil)
So(string(data), ShouldContainSubstring,
"skipping adding to the scheduler a generator for updating signatures validity because "+
"given binary doesn't include this feature, please build a binary that does so")
})
}
+159 -1
View File
@@ -9,7 +9,9 @@ import (
"net/http"
"net/url"
"os"
"path"
"testing"
"time"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/resty.v1"
@@ -20,6 +22,10 @@ import (
"zotregistry.io/zot/pkg/extensions"
extconf "zotregistry.io/zot/pkg/extensions/config"
syncconf "zotregistry.io/zot/pkg/extensions/config/sync"
"zotregistry.io/zot/pkg/extensions/monitoring"
"zotregistry.io/zot/pkg/log"
"zotregistry.io/zot/pkg/storage"
"zotregistry.io/zot/pkg/storage/local"
"zotregistry.io/zot/pkg/test"
)
@@ -518,6 +524,158 @@ func TestMgmtExtension(t *testing.T) {
data, _ := os.ReadFile(logFile.Name())
So(string(data), ShouldContainSubstring, "setting up mgmt routes")
})
Convey("Verify mgmt route enabled for uploading certificates and public keys", t, func() {
globalDir := t.TempDir()
conf := config.New()
port := test.GetFreePort()
conf.HTTP.Port = port
baseURL := test.GetBaseURL(port)
logFile, err := os.CreateTemp(globalDir, "zot-log*.txt")
So(err, ShouldBeNil)
defaultValue := true
conf.Commit = "v1.0.0"
imageStore := local.NewImageStore(globalDir, false, 0, false, false,
log.NewLogger("debug", logFile.Name()), monitoring.NewMetricsServer(false,
log.NewLogger("debug", logFile.Name())), nil, nil)
storeController := storage.StoreController{
DefaultStore: imageStore,
}
config, layers, manifest, err := test.GetRandomImageComponents(10)
So(err, ShouldBeNil)
err = test.WriteImageToFileSystem(
test.Image{
Manifest: manifest,
Layers: layers,
Config: config,
Reference: "0.0.1",
}, "repo", storeController,
)
So(err, ShouldBeNil)
sigConfig, sigLayers, sigManifest, err := test.GetRandomImageComponents(10)
So(err, ShouldBeNil)
ref, _ := test.GetCosignSignatureTagForManifest(manifest)
err = test.WriteImageToFileSystem(
test.Image{
Manifest: sigManifest,
Layers: sigLayers,
Config: sigConfig,
Reference: ref,
}, "repo", storeController,
)
So(err, ShouldBeNil)
conf.Extensions = &extconf.ExtensionConfig{}
conf.Extensions.Search = &extconf.SearchConfig{}
conf.Extensions.Search.Enable = &defaultValue
conf.Extensions.Mgmt = &extconf.MgmtConfig{
BaseConfig: extconf.BaseConfig{
Enable: &defaultValue,
},
}
conf.Log.Output = logFile.Name()
defer os.Remove(logFile.Name()) // cleanup
ctlr := api.NewController(conf)
ctlr.Config.Storage.RootDirectory = globalDir
ctlrManager := test.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
defer ctlrManager.StopServer()
rootDir := t.TempDir()
test.NotationPathLock.Lock()
defer test.NotationPathLock.Unlock()
test.LoadNotationPath(rootDir)
// generate a keypair
err = test.GenerateNotationCerts(rootDir, "test")
So(err, ShouldBeNil)
certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "test.crt"))
So(err, ShouldBeNil)
So(certificateContent, ShouldNotBeNil)
client := resty.New()
resp, err := client.R().SetHeader("Content-type", "application/octet-stream").
SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").SetQueryParam("truststoreName", "test").
SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().SetHeader("Content-type", "application/octet-stream").
SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").
SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
resp, err = client.R().SetHeader("Content-type", "application/octet-stream").
SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").SetQueryParam("truststoreName", "").
SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
resp, err = client.R().SetHeader("Content-type", "application/octet-stream").
SetQueryParam("resource", "signatures").SetQueryParam("tool", "notation").SetQueryParam("truststoreName", "test").
SetQueryParam("truststoreType", "signatureAuthority").
SetBody([]byte("wrong content")).Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
resp, err = client.R().SetHeader("Content-type", "application/octet-stream").
SetQueryParam("resource", "signatures").SetQueryParam("tool", "invalidTool").
SetBody(certificateContent).Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
resp, err = client.R().SetHeader("Content-type", "application/octet-stream").
SetQueryParam("resource", "signatures").SetQueryParam("tool", "cosign").
SetBody([]byte("wrong content")).Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
resp, err = client.R().SetQueryParam("resource", "signatures").SetQueryParam("tool", "cosign").
Get(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
resp, err = client.R().SetQueryParam("resource", "signatures").Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
resp, err = client.R().SetQueryParam("resource", "config").Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
resp, err = client.R().SetQueryParam("resource", "invalid").Post(baseURL + constants.FullMgmtPrefix)
So(err, ShouldBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
found, err := test.ReadLogFileAndSearchString(logFile.Name(), "setting up mgmt routes", time.Second)
So(err, ShouldBeNil)
So(found, ShouldBeTrue)
found, err = test.ReadLogFileAndSearchString(logFile.Name(), "updating signatures validity", 10*time.Second)
So(err, ShouldBeNil)
So(found, ShouldBeTrue)
found, err = test.ReadLogFileAndSearchString(logFile.Name(), "verifying signatures successfully completed",
time.Second)
So(err, ShouldBeNil)
So(found, ShouldBeTrue)
})
}
func TestMgmtWithBearer(t *testing.T) {
@@ -694,7 +852,7 @@ func TestAllowedMethodsHeaderMgmt(t *testing.T) {
resp, _ := resty.R().Options(baseURL + constants.FullMgmtPrefix)
So(resp, ShouldNotBeNil)
So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,OPTIONS")
So(resp.Header().Get("Access-Control-Allow-Methods"), ShouldResemble, "GET,POST,OPTIONS")
So(resp.StatusCode(), ShouldEqual, http.StatusNoContent)
})
}
+36
View File
@@ -10,7 +10,12 @@ Response depends on the user privileges:
| Supported queries | Input | Output | Description |
| --- | --- | --- | --- |
| [Get current configuration](#get-current-configuration) | None | config json | Get current zot configuration |
| [Upload a certificate](#post-certificate) | certificate | None | Add certificate for verifying notation signatures|
| [Upload a public key](#post-public-key) | public key | None | Add public key for verifying cosign signatures |
## General usage
The mgmt endpoint accepts as a query parameter what `resource` is targeted by the request and then all other required parameters for the specified resource. The default value of this
query parameter is `config`.
## Get current configuration
@@ -42,3 +47,34 @@ If ldap or htpasswd are enabled mgmt will return `{"htpasswd": {}}` indicating t
If any key is present under `'auth'` key, in the mgmt response, it means that particular authentication method is enabled.
## Configure zot for verifying signatures
If the `resource` is `signatures` then the mgmt endpoint accepts as a query parameter the `tool` that corresponds to the uploaded file and then all other required parameters for the specified tool.
### Upload a certificate
**Sample request**
| Tool | Parameter | Parameter Type | Parameter Description |
| --- | --- | --- | --- |
| notation | truststoreType | string | The type of the truststore. This parameter is optional and its default value is `ca` |
| | truststoreName | string | The name of the truststore |
```bash
curl --data-binary @certificate.crt -X POST http://localhost:8080/v2/_zot/ext/mgmt?resource=signature&tool=notation&truststoreType=ca&truststoreName=newtruststore
```
As a result of this request, the uploaded file will be stored in `_notation/truststore/x509/{truststoreType}/{truststoreName}` directory under $rootDir. And `truststores` field from `_notation/trustpolicy.json` file will be updated.
### Upload a public key
**Sample request**
| Tool | Parameter | Parameter Type | Parameter Description |
| --- | --- | --- | --- |
| cosign |
```bash
curl --data-binary @publicKey.pub -X POST http://localhost:8080/v2/_zot/ext/mgmt?resource=signature&tool=cosign
```
As a result of this request, the uploaded file will be stored in `_cosign` directory under $rootDir.