diff --git a/errors/errors.go b/errors/errors.go index 1912ccc9..71a595f1 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -97,4 +97,7 @@ var ( ErrSyncImageNotSigned = errors.New("sync: image is not signed") ErrSyncImageFilteredOut = errors.New("sync: image is filtered out by sync config") ErrCallerInfo = errors.New("runtime: failed to get info regarding the current runtime") + ErrInvalidTruststoreType = errors.New("signatures: invalid truststore type") + ErrInvalidTruststoreName = errors.New("signatures: invalid truststore name") + ErrInvalidCertificateContent = errors.New("signatures: invalid certificate content") ) diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 7d995487..dd1131c0 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -361,6 +361,12 @@ func (c *Controller) StartBackgroundTasks(reloadCtx context.Context) { c.SyncOnDemand = syncOnDemand } + + if c.Config.Extensions != nil { + if c.Config.Extensions.Mgmt != nil && *c.Config.Extensions.Mgmt.Enable { + ext.EnablePeriodicSignaturesVerification(c.Config, taskScheduler, c.RepoDB, c.Log) //nolint: contextcheck + } + } } type SyncOnDemand interface { diff --git a/pkg/extensions/extension_mgmt.go b/pkg/extensions/extension_mgmt.go index cd4bc53e..8c3b31b1 100644 --- a/pkg/extensions/extension_mgmt.go +++ b/pkg/extensions/extension_mgmt.go @@ -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 +} diff --git a/pkg/extensions/extension_mgmt_disabled.go b/pkg/extensions/extension_mgmt_disabled.go index 1788f34e..75d89955 100644 --- a/pkg/extensions/extension_mgmt_disabled.go +++ b/pkg/extensions/extension_mgmt_disabled.go @@ -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") +} diff --git a/pkg/extensions/extension_mgmt_disabled_test.go b/pkg/extensions/extension_mgmt_disabled_test.go new file mode 100644 index 00000000..15a03f10 --- /dev/null +++ b/pkg/extensions/extension_mgmt_disabled_test.go @@ -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") + }) +} diff --git a/pkg/extensions/extensions_test.go b/pkg/extensions/extensions_test.go index 65d573d0..73d79b0d 100644 --- a/pkg/extensions/extensions_test.go +++ b/pkg/extensions/extensions_test.go @@ -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) }) } diff --git a/pkg/extensions/mgmt.md b/pkg/extensions/mgmt.md index c1fe37c0..9d0421ed 100644 --- a/pkg/extensions/mgmt.md +++ b/pkg/extensions/mgmt.md @@ -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. diff --git a/pkg/meta/repodb/boltdb-wrapper/boltdb_wrapper_test.go b/pkg/meta/repodb/boltdb-wrapper/boltdb_wrapper_test.go index d109fe5f..e9e26701 100644 --- a/pkg/meta/repodb/boltdb-wrapper/boltdb_wrapper_test.go +++ b/pkg/meta/repodb/boltdb-wrapper/boltdb_wrapper_test.go @@ -3,15 +3,8 @@ package bolt_test import ( "context" "encoding/json" - "errors" - "os" - "path" "testing" - "time" - "github.com/notaryproject/notation-core-go/signature/jws" - "github.com/notaryproject/notation-go" - "github.com/notaryproject/notation-go/signer" "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" . "github.com/smartystreets/goconvey/convey" @@ -1013,169 +1006,6 @@ func TestWrapperErrors(t *testing.T) { err = boltdbWrapper.UpdateSignaturesValidity("repo1", digest.FromString("dig")) So(err, ShouldNotBeNil) }) - - Convey("VerifySignature -> untrusted signature", func() { - err := boltdbWrapper.SetManifestData(digest.FromString("dig"), repodb.ManifestData{ - ManifestBlob: []byte("Bad Manifest"), - ConfigBlob: []byte("Bad Manifest"), - }) - So(err, ShouldBeNil) - - err = boltdbWrapper.DB.Update(func(tx *bbolt.Tx) error { - repoBuck := tx.Bucket([]byte(bolt.RepoMetadataBucket)) - - return repoBuck.Put([]byte("repo1"), repoMetaBlob) - }) - So(err, ShouldBeNil) - - layerInfo := repodb.LayerInfo{LayerDigest: "", LayerContent: []byte{}, SignatureKey: ""} - - err = boltdbWrapper.AddManifestSignature("repo1", digest.FromString("dig"), - repodb.SignatureMetadata{ - SignatureType: signatures.CosignSignature, - SignatureDigest: string(digest.FromString("signature digest")), - LayersInfo: []repodb.LayerInfo{layerInfo}, - }) - So(err, ShouldBeNil) - - err = boltdbWrapper.UpdateSignaturesValidity("repo1", digest.FromString("dig")) - So(err, ShouldBeNil) - - repoData, err := boltdbWrapper.GetRepoMeta("repo1") - So(err, ShouldBeNil) - So(repoData.Signatures[string(digest.FromString("dig"))][signatures.CosignSignature][0].LayersInfo[0].Signer, - ShouldBeEmpty) - So(repoData.Signatures[string(digest.FromString("dig"))][signatures.CosignSignature][0].LayersInfo[0].Date, - ShouldBeZeroValue) - }) - - Convey("VerifySignature -> trusted signature", func() { - _, _, manifest, _ := test.GetRandomImageComponents(10) - manifestContent, _ := json.Marshal(manifest) - manifestDigest := digest.FromBytes(manifestContent) - - err := boltdbWrapper.SetManifestData(manifestDigest, repodb.ManifestData{ - ManifestBlob: manifestContent, - ConfigBlob: []byte("configContent"), - }) - So(err, ShouldBeNil) - - err = boltdbWrapper.DB.Update(func(tx *bbolt.Tx) error { - repoBuck := tx.Bucket([]byte(bolt.RepoMetadataBucket)) - - return repoBuck.Put([]byte("repo"), repoMetaBlob) - }) - So(err, ShouldBeNil) - - mediaType := jws.MediaTypeEnvelope - - signOpts := notation.SignerSignOptions{ - SignatureMediaType: mediaType, - PluginConfig: map[string]string{}, - ExpiryDuration: 24 * time.Hour, - } - - tdir := t.TempDir() - keyName := "notation-sign-test" - - test.NotationPathLock.Lock() - defer test.NotationPathLock.Unlock() - - test.LoadNotationPath(tdir) - - err = test.GenerateNotationCerts(tdir, keyName) - So(err, ShouldBeNil) - - // getSigner - var newSigner notation.Signer - - // ResolveKey - signingKeys, err := test.LoadNotationSigningkeys(tdir) - So(err, ShouldBeNil) - - idx := test.Index(signingKeys.Keys, keyName) - So(idx, ShouldBeGreaterThanOrEqualTo, 0) - - key := signingKeys.Keys[idx] - - if key.X509KeyPair != nil { - newSigner, err = signer.NewFromFiles(key.X509KeyPair.KeyPath, key.X509KeyPair.CertificatePath) - So(err, ShouldBeNil) - } - - descToSign := ispec.Descriptor{ - MediaType: manifest.MediaType, - Digest: manifestDigest, - Size: int64(len(manifestContent)), - } - sig, _, err := newSigner.Sign(ctx, descToSign, signOpts) - So(err, ShouldBeNil) - - layerInfo := repodb.LayerInfo{ - LayerDigest: string(digest.FromBytes(sig)), - LayerContent: sig, SignatureKey: mediaType, - } - - err = boltdbWrapper.AddManifestSignature("repo", manifestDigest, - repodb.SignatureMetadata{ - SignatureType: signatures.NotationSignature, - SignatureDigest: string(digest.FromString("signature digest")), - LayersInfo: []repodb.LayerInfo{layerInfo}, - }) - So(err, ShouldBeNil) - - err = signatures.InitNotationDir(tdir) - So(err, ShouldBeNil) - - trustpolicyPath := path.Join(tdir, "_notation/trustpolicy.json") - - if _, err := os.Stat(trustpolicyPath); errors.Is(err, os.ErrNotExist) { - trustPolicy := ` - { - "version": "1.0", - "trustPolicies": [ - { - "name": "notation-sign-test", - "registryScopes": [ "*" ], - "signatureVerification": { - "level" : "strict" - }, - "trustStores": ["ca:notation-sign-test"], - "trustedIdentities": [ - "*" - ] - } - ] - }` - - file, err := os.Create(trustpolicyPath) - So(err, ShouldBeNil) - - defer file.Close() - - _, err = file.WriteString(trustPolicy) - So(err, ShouldBeNil) - } - - truststore := "_notation/truststore/x509/ca/notation-sign-test" - truststoreSrc := "notation/truststore/x509/ca/notation-sign-test" - err = os.MkdirAll(path.Join(tdir, truststore), 0o755) - So(err, ShouldBeNil) - - err = test.CopyFile(path.Join(tdir, truststoreSrc, "notation-sign-test.crt"), - path.Join(tdir, truststore, "notation-sign-test.crt")) - So(err, ShouldBeNil) - - err = boltdbWrapper.UpdateSignaturesValidity("repo", manifestDigest) //nolint:contextcheck - So(err, ShouldBeNil) - - repoData, err := boltdbWrapper.GetRepoMeta("repo") - So(err, ShouldBeNil) - So(repoData.Signatures[string(manifestDigest)][signatures.NotationSignature][0].LayersInfo[0].Signer, - ShouldNotBeEmpty) - So(repoData.Signatures[string(manifestDigest)][signatures.NotationSignature][0].LayersInfo[0].Date, - ShouldNotBeZeroValue) - }) }) }) } diff --git a/pkg/meta/repodb/dynamodb-wrapper/dynamo_test.go b/pkg/meta/repodb/dynamodb-wrapper/dynamo_test.go index bddcd9a6..1f8deb44 100644 --- a/pkg/meta/repodb/dynamodb-wrapper/dynamo_test.go +++ b/pkg/meta/repodb/dynamodb-wrapper/dynamo_test.go @@ -563,6 +563,25 @@ func TestWrapperErrors(t *testing.T) { So(err, ShouldNotBeNil) }) + Convey("UpdateSignaturesValidity GetManifestData error", func() { + err := setBadManifestData(dynamoWrapper.Client, manifestDataTablename, "dig") + So(err, ShouldBeNil) + + err = dynamoWrapper.UpdateSignaturesValidity("repo", "dig") + So(err, ShouldNotBeNil) + }) + + Convey("UpdateSignaturesValidity GetRepoMeta error", func() { + err := dynamoWrapper.SetManifestData("dig", repodb.ManifestData{}) + So(err, ShouldBeNil) + + err = setBadRepoMeta(dynamoWrapper.Client, repoMetaTablename, "repo") + So(err, ShouldBeNil) + + err = dynamoWrapper.UpdateSignaturesValidity("repo", "dig") + So(err, ShouldNotBeNil) + }) + Convey("AddManifestSignature GetRepoMeta error", func() { err := dynamoWrapper.SetRepoReference("repo", "tag", "dig", "") So(err, ShouldBeNil) diff --git a/pkg/meta/repodb/dynamodb-wrapper/dynamo_wrapper.go b/pkg/meta/repodb/dynamodb-wrapper/dynamo_wrapper.go index 6f02e19f..302bd3ce 100644 --- a/pkg/meta/repodb/dynamodb-wrapper/dynamo_wrapper.go +++ b/pkg/meta/repodb/dynamodb-wrapper/dynamo_wrapper.go @@ -618,7 +618,67 @@ func (dwr *DBWrapper) IncrementImageDownloads(repo string, reference string) err } func (dwr *DBWrapper) UpdateSignaturesValidity(repo string, manifestDigest godigest.Digest) error { - return nil + // get ManifestData of signed manifest + var blob []byte + + manifestData, err := dwr.GetManifestData(manifestDigest) + if err != nil { + if errors.Is(err, zerr.ErrManifestDataNotFound) { + indexData, err := dwr.GetIndexData(manifestDigest) + if err != nil { + return nil //nolint: nilerr + } + + blob = indexData.IndexBlob + } else { + return fmt.Errorf("%w for manifest '%s' from repo '%s'", errRepodb, manifestDigest, repo) + } + } else { + blob = manifestData.ManifestBlob + } + + // update signatures with details about validity and author + repoMeta, err := dwr.GetRepoMeta(repo) + if err != nil { + return err + } + + manifestSignatures := repodb.ManifestSignatures{} + + for sigType, sigs := range repoMeta.Signatures[manifestDigest.String()] { + signaturesInfo := []repodb.SignatureInfo{} + + for _, sigInfo := range sigs { + layersInfo := []repodb.LayerInfo{} + + for _, layerInfo := range sigInfo.LayersInfo { + author, date, isTrusted, _ := signatures.VerifySignature(sigType, layerInfo.LayerContent, layerInfo.SignatureKey, + manifestDigest, blob, repo) + + if isTrusted { + layerInfo.Signer = author + } + + if !date.IsZero() { + layerInfo.Signer = author + layerInfo.Date = date + } + + layersInfo = append(layersInfo, layerInfo) + } + + signaturesInfo = append(signaturesInfo, repodb.SignatureInfo{ + SignatureManifestDigest: sigInfo.SignatureManifestDigest, + LayersInfo: layersInfo, + }) + } + + manifestSignatures[sigType] = signaturesInfo + } + + repoMeta.Signatures[manifestDigest.String()] = manifestSignatures + + return dwr.SetRepoMeta(repoMeta.Name, repoMeta) } func (dwr *DBWrapper) AddManifestSignature(repo string, signedManifestDigest godigest.Digest, diff --git a/pkg/meta/repodb/repodb_test.go b/pkg/meta/repodb/repodb_test.go index 5a272add..48c98898 100644 --- a/pkg/meta/repodb/repodb_test.go +++ b/pkg/meta/repodb/repodb_test.go @@ -13,6 +13,9 @@ import ( "time" guuid "github.com/gofrs/uuid" + "github.com/notaryproject/notation-core-go/signature/jws" + "github.com/notaryproject/notation-go" + "github.com/notaryproject/notation-go/signer" godigest "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -25,6 +28,7 @@ import ( "zotregistry.io/zot/pkg/meta/repodb" boltdb_wrapper "zotregistry.io/zot/pkg/meta/repodb/boltdb-wrapper" dynamodb_wrapper "zotregistry.io/zot/pkg/meta/repodb/dynamodb-wrapper" + "zotregistry.io/zot/pkg/meta/signatures" localCtx "zotregistry.io/zot/pkg/requestcontext" "zotregistry.io/zot/pkg/test" ) @@ -71,7 +75,7 @@ func TestBoltDBWrapper(t *testing.T) { So(boltdbWrapper, ShouldNotBeNil) So(err, ShouldBeNil) - RunRepoDBTests(boltdbWrapper) + RunRepoDBTests(t, boltdbWrapper) }) } @@ -122,11 +126,11 @@ func TestDynamoDBWrapper(t *testing.T) { return err } - RunRepoDBTests(dynamoDriver, resetDynamoDBTables) + RunRepoDBTests(t, dynamoDriver, resetDynamoDBTables) }) } -func RunRepoDBTests(repoDB repodb.RepoDB, preparationFuncs ...func() error) { +func RunRepoDBTests(t *testing.T, repoDB repodb.RepoDB, preparationFuncs ...func() error) { //nolint: thelper Convey("Test RepoDB Interface implementation", func() { for _, prepFunc := range preparationFuncs { err := prepFunc() @@ -994,6 +998,170 @@ func RunRepoDBTests(repoDB repodb.RepoDB, preparationFuncs ...func() error) { So(err, ShouldNotBeNil) }) + Convey("Test UpdateSignaturesValidity", func() { + Convey("untrusted signature", func() { + var ( + repo1 = "repo1" + tag1 = "0.0.1" + manifestDigest1 = godigest.FromString("dig") + ) + + err := repoDB.SetRepoReference(repo1, tag1, manifestDigest1, ispec.MediaTypeImageManifest) + So(err, ShouldBeNil) + + err = repoDB.SetManifestMeta(repo1, manifestDigest1, repodb.ManifestMetadata{ + ManifestBlob: []byte("Bad Manifest"), + ConfigBlob: []byte("Bad Manifest"), + }) + So(err, ShouldBeNil) + + layerInfo := repodb.LayerInfo{LayerDigest: "", LayerContent: []byte{}, SignatureKey: ""} + + err = repoDB.AddManifestSignature(repo1, manifestDigest1, repodb.SignatureMetadata{ + SignatureType: "cosign", + SignatureDigest: string(manifestDigest1), + LayersInfo: []repodb.LayerInfo{layerInfo}, + }) + So(err, ShouldBeNil) + + err = repoDB.UpdateSignaturesValidity(repo1, manifestDigest1) + So(err, ShouldBeNil) + + repoData, err := repoDB.GetRepoMeta(repo1) + So(err, ShouldBeNil) + So(repoData.Signatures[string(manifestDigest1)]["cosign"][0].LayersInfo[0].Signer, + ShouldBeEmpty) + So(repoData.Signatures[string(manifestDigest1)]["cosign"][0].LayersInfo[0].Date, + ShouldBeZeroValue) + }) + + Convey("trusted signature", func() { + _, _, manifest, _ := test.GetRandomImageComponents(10) + manifestContent, _ := json.Marshal(manifest) + manifestDigest := godigest.FromBytes(manifestContent) + repo := "repo" + tag := "0.0.1" + + err := repoDB.SetRepoReference(repo, tag, manifestDigest, ispec.MediaTypeImageManifest) + So(err, ShouldBeNil) + + err = repoDB.SetManifestMeta(repo, manifestDigest, repodb.ManifestMetadata{ + ManifestBlob: manifestContent, + ConfigBlob: []byte("configContent"), + }) + So(err, ShouldBeNil) + + mediaType := jws.MediaTypeEnvelope + + signOpts := notation.SignerSignOptions{ + SignatureMediaType: mediaType, + PluginConfig: map[string]string{}, + ExpiryDuration: 24 * time.Hour, + } + + tdir := t.TempDir() + keyName := "notation-sign-test" + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(tdir) + + err = test.GenerateNotationCerts(tdir, keyName) + So(err, ShouldBeNil) + + // getSigner + var newSigner notation.Signer + + // ResolveKey + signingKeys, err := test.LoadNotationSigningkeys(tdir) + So(err, ShouldBeNil) + + idx := test.Index(signingKeys.Keys, keyName) + So(idx, ShouldBeGreaterThanOrEqualTo, 0) + + key := signingKeys.Keys[idx] + + if key.X509KeyPair != nil { + newSigner, err = signer.NewFromFiles(key.X509KeyPair.KeyPath, key.X509KeyPair.CertificatePath) + So(err, ShouldBeNil) + } + + descToSign := ispec.Descriptor{ + MediaType: manifest.MediaType, + Digest: manifestDigest, + Size: int64(len(manifestContent)), + } + + ctx := context.Background() + + sig, _, err := newSigner.Sign(ctx, descToSign, signOpts) + So(err, ShouldBeNil) + + layerInfo := repodb.LayerInfo{ + LayerDigest: string(godigest.FromBytes(sig)), + LayerContent: sig, SignatureKey: mediaType, + } + + err = repoDB.AddManifestSignature(repo, manifestDigest, repodb.SignatureMetadata{ + SignatureType: "notation", + SignatureDigest: string(godigest.FromString("signature digest")), + LayersInfo: []repodb.LayerInfo{layerInfo}, + }) + So(err, ShouldBeNil) + + err = signatures.InitNotationDir(tdir) + So(err, ShouldBeNil) + + trustpolicyPath := path.Join(tdir, "_notation/trustpolicy.json") + + trustPolicy := ` + { + "version": "1.0", + "trustPolicies": [ + { + "name": "notation-sign-test", + "registryScopes": [ "*" ], + "signatureVerification": { + "level" : "strict" + }, + "trustStores": ["ca:notation-sign-test"], + "trustedIdentities": [ + "*" + ] + } + ] + }` + + file, err := os.Create(trustpolicyPath) + So(err, ShouldBeNil) + + defer file.Close() + + _, err = file.WriteString(trustPolicy) + So(err, ShouldBeNil) + + truststore := "_notation/truststore/x509/ca/notation-sign-test" + truststoreSrc := "notation/truststore/x509/ca/notation-sign-test" + err = os.MkdirAll(path.Join(tdir, truststore), 0o755) + So(err, ShouldBeNil) + + err = test.CopyFile(path.Join(tdir, truststoreSrc, "notation-sign-test.crt"), + path.Join(tdir, truststore, "notation-sign-test.crt")) + So(err, ShouldBeNil) + + err = repoDB.UpdateSignaturesValidity(repo, manifestDigest) //nolint:contextcheck + So(err, ShouldBeNil) + + repoData, err := repoDB.GetRepoMeta(repo) + So(err, ShouldBeNil) + So(repoData.Signatures[string(manifestDigest)]["notation"][0].LayersInfo[0].Signer, + ShouldNotBeEmpty) + So(repoData.Signatures[string(manifestDigest)]["notation"][0].LayersInfo[0].Date, + ShouldNotBeZeroValue) + }) + }) + Convey("Test AddImageSignature with inverted order", func() { var ( repo1 = "repo1" diff --git a/pkg/meta/signatures/README.md b/pkg/meta/signatures/README.md new file mode 100644 index 00000000..26ed4220 --- /dev/null +++ b/pkg/meta/signatures/README.md @@ -0,0 +1,132 @@ +# Verifying signatures + +## How to configure zot for verifying signatures + +In order to configure zot for verifying signatures, the user should provide: + +1. public keys (which correspond to the private keys used to sign images with `cosign`) + +or + +2. certificates (used to sign images with `notation`) + +These files could be uploaded using one of these requests: + +1. upload a public key + + ***Example of request*** + ``` + curl --data-binary @file.pub -X POST "http://localhost:8080/v2/_zot/ext/mgmt?resource=signatures&tool=cosign" + ``` + +2. upload a certificate + + ***Example of request*** + ``` + curl --data-binary @filet.crt -X POST "http://localhost:8080/v2/_zot/ext/mgmt?resource=signatures&tool=notation&truststoreType=ca&truststoreName=upload-cert" + ``` + +Besides the requested files, the user should also specify the `tool` which should be : + +- `cosign` for uploading public keys +- `notation` for uploading certificates + + Also, if the uploaded file is a certificate then the user should also specify the type of the truststore through `truststoreType` param and also its name through `truststoreName` param. + + Based on the uploaded files, signatures verification will be performed for all the signed images. Then the information known about the signatures will be: + +- the tool used to generate the signature (`cosign` or `notation`) +- info about the trustworthiness of the signature (if there is a certificate or a public key which can successfully verify the signature) +- the author of the signature which will be: + + - the public key -> for signatures generated using `cosign` + - the subject of the certificate -> for signatures generated using `notation` + +**Example of GraphQL output** + +```json +{ + "data": { + "Image": { + "Manifests": [ + { + "Digest":"sha256:6c19fba547b87bde9a45df2f8563e0c61826d098dd30192a2c8b86da1e1a6360" + } + ], + "IsSigned": true, + "Tag": "latest", + "SignatureInfo":[ + { + "Tool":"cosign", + "IsTrusted":false, + "Author":"" + }, + { + "Tool":"cosign", + "IsTrusted":false, + "Author":"" + }, + { + "Tool":"cosign", + "IsTrusted": true, + "Author":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9pN+/hGcFlh4YYaNvZxNvuh8Qyhl\npURz77qScOHe3DqdmiWiuqIseyhEdjEDwpL6fHRwu3a2Nd9wbKqm0la76w==\n-----END PUBLIC KEY-----\n" + }, + { + "Tool":"notation", + "IsTrusted": false, + "Author":"CN=v4-test,O=Notary,L=Seattle,ST=WA,C=US" + }, + { + "Tool":"notation", + "IsTrusted": true, + "Author":"CN=multipleSig,O=Notary,L=Seattle,ST=WA,C=US" + } + ] + } + } +} +``` + +## Notes + +- The files (public keys and certificates) uploaded using the exposed routes will be stored in some specific directories called `_cosign` and `_notation` under `$rootDir`. + + - `_cosign` directory will contain the uploaded public keys + ``` + _cosign + ├── $publicKey1 + └── $publicKey2 + ``` + + - `_notation` directory will have this structure: + + ``` + _notation + ├── trustpolicy.json + └── truststore + └── x509 + └── $truststoreType + └── $truststoreName + └── $certificate + ``` + + where `trustpolicy.json` file has this default content which can not be modified by the user and which is updated each time a new certificate is added to a new truststore: + ``` + { + "version": "1.0", + "trustPolicies": [ + { + "name": "default-config", + "registryScopes": [ "*" ], + "signatureVerification": { + "level" : "strict" + }, + "trustStores": [], + "trustedIdentities": [ + "*" + ] + } + ] + } + ``` + diff --git a/pkg/meta/signatures/cosign.go b/pkg/meta/signatures/cosign.go index 953d3e0f..822738b2 100644 --- a/pkg/meta/signatures/cosign.go +++ b/pkg/meta/signatures/cosign.go @@ -12,6 +12,7 @@ import ( godigest "github.com/opencontainers/go-digest" "github.com/sigstore/cosign/v2/pkg/cosign/pkcs11key" sigs "github.com/sigstore/cosign/v2/pkg/signature" + "github.com/sigstore/sigstore/pkg/cryptoutils" "github.com/sigstore/sigstore/pkg/signature/options" zerr "zotregistry.io/zot/errors" @@ -111,3 +112,32 @@ func VerifyCosignSignature( return "", false, nil } + +func UploadPublicKey(publicKeyContent []byte) error { + // validate public key + if ok, err := validatePublicKey(publicKeyContent); !ok { + return err + } + + // add public key to "{rootDir}/_cosign/{name.pub}" + configDir, err := GetCosignDirPath() + if err != nil { + return err + } + + name := godigest.FromBytes(publicKeyContent) + + // store public key + publicKeyPath := path.Join(configDir, name.String()) + + return os.WriteFile(publicKeyPath, publicKeyContent, defaultFilePerms) +} + +func validatePublicKey(publicKeyContent []byte) (bool, error) { + _, err := cryptoutils.UnmarshalPEMToPublicKey(publicKeyContent) + if err != nil { + return false, err + } + + return true, nil +} diff --git a/pkg/meta/signatures/notation.go b/pkg/meta/signatures/notation.go index d4fa32ed..5b2475b5 100644 --- a/pkg/meta/signatures/notation.go +++ b/pkg/meta/signatures/notation.go @@ -2,19 +2,26 @@ package signatures import ( "context" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "os" "path" + "path/filepath" + "regexp" + "sync" "time" + _ "github.com/notaryproject/notation-core-go/signature/jws" "github.com/notaryproject/notation-go" "github.com/notaryproject/notation-go/dir" "github.com/notaryproject/notation-go/plugin" "github.com/notaryproject/notation-go/verifier" "github.com/notaryproject/notation-go/verifier/trustpolicy" "github.com/notaryproject/notation-go/verifier/truststore" + godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" zerr "zotregistry.io/zot/errors" @@ -22,7 +29,10 @@ import ( const notationDirRelativePath = "_notation" -var notationDir = "" //nolint:gochecknoglobals +var ( + notationDir = "" //nolint:gochecknoglobals + TrustpolicyLock = new(sync.Mutex) //nolint: gochecknoglobals +) func InitNotationDir(rootDir string) error { dir := path.Join(rootDir, notationDirRelativePath) @@ -37,11 +47,48 @@ func InitNotationDir(rootDir string) error { if err == nil { notationDir = dir + + if _, err := LoadTrustPolicyDocument(notationDir); os.IsNotExist(err) { + return InitTrustpolicyFile(notationDir) + } } return err } +func InitTrustpolicyFile(configDir string) error { + // according to https://github.com/notaryproject/notation/blob/main/specs/commandline/verify.md + // the value of signatureVerification.level field from trustpolicy.json file + // could be one of these values: `strict`, `permissive`, `audit` or `skip` + // this default trustpolicy.json file sets the signatureVerification.level + // to `strict` which enforces all validations (this means that even if there is + // a certificate that verifies a signature, but that certificate has expired, then the + // signature is not trusted; if this field were set to `permissive` then the + // signature would be trusted) + trustPolicy := ` + { + "version": "1.0", + "trustPolicies": [ + { + "name": "default-config", + "registryScopes": [ "*" ], + "signatureVerification": { + "level" : "strict" + }, + "trustStores": [], + "trustedIdentities": [ + "*" + ] + } + ] + }` + + TrustpolicyLock.Lock() + defer TrustpolicyLock.Unlock() + + return os.WriteFile(path.Join(configDir, dir.PathTrustPolicy), []byte(trustPolicy), defaultDirPerms) +} + func GetNotationDirPath() (string, error) { if notationDir != "" { return notationDir, nil @@ -79,6 +126,9 @@ func NewFromConfig() (notation.Verifier, error) { } // Load trust policy. + TrustpolicyLock.Lock() + defer TrustpolicyLock.Unlock() + policyDocument, err := LoadTrustPolicyDocument(notationDir) if err != nil { return nil, err @@ -165,3 +215,112 @@ func CheckExpiryErr(verificationResults []*notation.ValidationResult, notAfter t return false } + +func UploadCertificate(certificateContent []byte, truststoreType, truststoreName string) error { + // validate truststore type + if !validateTruststoreType(truststoreType) { + return zerr.ErrInvalidTruststoreType + } + + // validate truststore name + if !validateTruststoreName(truststoreName) { + return zerr.ErrInvalidTruststoreName + } + + // validate certificate + if ok, err := validateCertificate(certificateContent); !ok { + return err + } + + // add certificate to "{rootDir}/_notation/truststore/x509/{type}/{name}/{name.crt}" + configDir, err := GetNotationDirPath() + if err != nil { + return err + } + + name := godigest.FromBytes(certificateContent) + + // store certificate + truststorePath := path.Join(configDir, dir.TrustStoreDir, "x509", truststoreType, truststoreName, name.String()) + + if err := os.MkdirAll(filepath.Dir(truststorePath), defaultDirPerms); err != nil { + return err + } + + err = os.WriteFile(truststorePath, certificateContent, defaultFilePerms) + if err != nil { + return err + } + + // add certificate to "trustpolicy.json" + TrustpolicyLock.Lock() + defer TrustpolicyLock.Unlock() + + trustpolicyDoc, err := LoadTrustPolicyDocument(configDir) + if err != nil { + return err + } + + truststoreToAppend := fmt.Sprintf("%s:%s", truststoreType, truststoreName) + + for _, t := range trustpolicyDoc.TrustPolicies[0].TrustStores { + if t == truststoreToAppend { + return nil + } + } + + trustpolicyDoc.TrustPolicies[0].TrustStores = append(trustpolicyDoc.TrustPolicies[0].TrustStores, truststoreToAppend) + + trustpolicyDocContent, err := json.Marshal(trustpolicyDoc) + if err != nil { + return err + } + + return os.WriteFile(path.Join(configDir, dir.PathTrustPolicy), trustpolicyDocContent, defaultFilePerms) +} + +func validateTruststoreType(truststoreType string) bool { + for _, t := range truststore.Types { + if string(t) == truststoreType { + return true + } + } + + return false +} + +func validateTruststoreName(truststoreName string) bool { + return regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`).MatchString(truststoreName) +} + +// implementation from https://github.com/notaryproject/notation-core-go/blob/main/x509/cert.go#L20 +func validateCertificate(certificateContent []byte) (bool, error) { + var certs []*x509.Certificate + + block, rest := pem.Decode(certificateContent) + if block == nil { + // data may be in DER format + derCerts, err := x509.ParseCertificates(certificateContent) + if err != nil { + return false, err + } + + certs = append(certs, derCerts...) + } else { + // data is in PEM format + for block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, err + } + certs = append(certs, cert) + block, rest = pem.Decode(rest) + } + } + + if len(certs) == 0 { + return false, zerr.ErrInvalidCertificateContent + } + + return true, nil +} diff --git a/pkg/meta/signatures/signatures.go b/pkg/meta/signatures/signatures.go index 5b266302..a3c2b636 100644 --- a/pkg/meta/signatures/signatures.go +++ b/pkg/meta/signatures/signatures.go @@ -14,6 +14,7 @@ const ( CosignSignature = "cosign" NotationSignature = "notation" defaultDirPerms = 0o700 + defaultFilePerms = 0o644 ) func InitCosignAndNotationDirs(rootDir string) error { diff --git a/pkg/meta/signatures/signatures_test.go b/pkg/meta/signatures/signatures_test.go index 27905f3d..58760845 100644 --- a/pkg/meta/signatures/signatures_test.go +++ b/pkg/meta/signatures/signatures_test.go @@ -56,6 +56,9 @@ func TestInitCosignAndNotationDirs(t *testing.T) { err = signatures.InitCosignAndNotationDirs(dir) So(err, ShouldNotBeNil) + err = signatures.InitNotationDir(dir) + So(err, ShouldNotBeNil) + err = os.Chmod(dir, 0o500) So(err, ShouldBeNil) @@ -70,6 +73,51 @@ func TestInitCosignAndNotationDirs(t *testing.T) { So(err, ShouldNotBeNil) So(err, ShouldEqual, zerr.ErrSignConfigDirNotSet) }) + + Convey("UploadCertificate - notationDir is not set", t, func() { + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err := test.GenerateNotationCerts(rootDir, "notation-upload-test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "notation-upload-test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + err = signatures.UploadCertificate(certificateContent, "ca", "notation-upload-test") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, zerr.ErrSignConfigDirNotSet) + }) + + Convey("UploadPublicKey - cosignDir is not set", t, func() { + rootDir := t.TempDir() + + cwd, err := os.Getwd() + So(err, ShouldBeNil) + + _ = os.Chdir(rootDir) + + // generate a keypair + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + _ = os.Chdir(cwd) + + publicKeyContent, err := os.ReadFile(path.Join(rootDir, "cosign.pub")) + So(err, ShouldBeNil) + So(publicKeyContent, ShouldNotBeNil) + + err = signatures.UploadPublicKey(publicKeyContent) + So(err, ShouldNotBeNil) + So(err, ShouldEqual, zerr.ErrSignConfigDirNotSet) + }) } func TestVerifySignatures(t *testing.T) { @@ -278,6 +326,11 @@ func TestVerifySignatures(t *testing.T) { err := signatures.InitNotationDir(dir) So(err, ShouldBeNil) + notationDir, _ := signatures.GetNotationDirPath() + + err = os.Remove(path.Join(notationDir, "trustpolicy.json")) + So(err, ShouldBeNil) + _, _, _, err = signatures.VerifySignature("notation", []byte("signature"), "", manifestDigest, manifestContent, repo) So(err, ShouldNotBeNil) }) @@ -292,7 +345,7 @@ func TestVerifySignatures(t *testing.T) { So(err, ShouldBeNil) err = test.WriteFileWithPermission(path.Join(notationDir, "trustpolicy.json"), []byte("invalid content"), - 0o600, false) + 0o600, true) So(err, ShouldBeNil) _, _, _, err = signatures.VerifySignature("notation", []byte("signature"), "", manifestDigest, manifestContent, @@ -363,7 +416,7 @@ func TestVerifySignatures(t *testing.T) { ] }` - err = test.WriteFileWithPermission(path.Join(notationDir, "trustpolicy.json"), []byte(trustPolicy), 0o600, false) + err = test.WriteFileWithPermission(path.Join(notationDir, "trustpolicy.json"), []byte(trustPolicy), 0o600, true) So(err, ShouldBeNil) indexContent, err := ctlr.StoreController.DefaultStore.GetIndexContent(repo) @@ -437,3 +490,222 @@ func TestCheckExpiryErr(t *testing.T) { So(isExpiryErr, ShouldBeTrue) }) } + +func TestUploadPublicKey(t *testing.T) { + Convey("public key - invalid content", t, func() { + err := signatures.UploadPublicKey([]byte("wrong content")) + So(err, ShouldNotBeNil) + }) + + Convey("upload public key successfully", t, func() { + rootDir := t.TempDir() + + cwd, err := os.Getwd() + So(err, ShouldBeNil) + + _ = os.Chdir(rootDir) + + // generate a keypair + os.Setenv("COSIGN_PASSWORD", "") + err = generate.GenerateKeyPairCmd(context.TODO(), "", "cosign", nil) + So(err, ShouldBeNil) + + _ = os.Chdir(cwd) + + publicKeyContent, err := os.ReadFile(path.Join(rootDir, "cosign.pub")) + So(err, ShouldBeNil) + So(publicKeyContent, ShouldNotBeNil) + + err = signatures.InitCosignDir(rootDir) + So(err, ShouldBeNil) + + err = signatures.UploadPublicKey(publicKeyContent) + So(err, ShouldBeNil) + }) +} + +func TestUploadCertificate(t *testing.T) { + Convey("invalid truststore type", t, func() { + err := signatures.UploadCertificate([]byte("certificate content"), "wrongType", "store") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, zerr.ErrInvalidTruststoreType) + }) + + Convey("invalid truststore name", t, func() { + err := signatures.UploadCertificate([]byte("certificate content"), "ca", "*store?") + So(err, ShouldNotBeNil) + So(err, ShouldEqual, zerr.ErrInvalidTruststoreName) + }) + + Convey("invalid certificate content", t, func() { + err := signatures.UploadCertificate([]byte("invalid content"), "ca", "store") + So(err, ShouldNotBeNil) + + content := `-----BEGIN CERTIFICATE----- +-----END CERTIFICATE----- + ` + + err = signatures.UploadCertificate([]byte(content), "ca", "store") + So(err, ShouldNotBeNil) + + content = `` + + err = signatures.UploadCertificate([]byte(content), "ca", "store") + So(err, ShouldNotBeNil) + }) + + Convey("truststore dir can not be created", t, func() { + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err := test.GenerateNotationCerts(rootDir, "notation-upload-test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "notation-upload-test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + err = signatures.InitNotationDir(rootDir) + So(err, ShouldBeNil) + + notationDir, err := signatures.GetNotationDirPath() + So(err, ShouldBeNil) + + err = os.Chmod(notationDir, 0o100) + So(err, ShouldBeNil) + + err = signatures.UploadCertificate(certificateContent, "ca", "notation-upload-test") + So(err, ShouldNotBeNil) + + err = os.Chmod(notationDir, 0o777) + So(err, ShouldBeNil) + }) + + Convey("certificate can't be stored", t, func() { + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err := test.GenerateNotationCerts(rootDir, "notation-upload-test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "notation-upload-test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + err = signatures.InitNotationDir(rootDir) + So(err, ShouldBeNil) + + notationDir, err := signatures.GetNotationDirPath() + So(err, ShouldBeNil) + + err = os.MkdirAll(path.Join(notationDir, "truststore/x509/ca/notation-upload-test"), 0o777) + So(err, ShouldBeNil) + + err = os.Chmod(path.Join(notationDir, "truststore/x509/ca/notation-upload-test"), 0o100) + So(err, ShouldBeNil) + + err = signatures.UploadCertificate(certificateContent, "ca", "notation-upload-test") + So(err, ShouldNotBeNil) + }) + + Convey("trustpolicy - invalid content", t, func() { + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err := test.GenerateNotationCerts(rootDir, "notation-upload-test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "notation-upload-test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + err = signatures.InitNotationDir(rootDir) + So(err, ShouldBeNil) + + notationDir, err := signatures.GetNotationDirPath() + So(err, ShouldBeNil) + + err = test.WriteFileWithPermission(path.Join(notationDir, "trustpolicy.json"), []byte("invalid content"), + 0o600, true) + So(err, ShouldBeNil) + + err = signatures.UploadCertificate(certificateContent, "ca", "notation-upload-test") + So(err, ShouldNotBeNil) + }) + + Convey("trustpolicy - truststore already exists", t, func() { + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err := test.GenerateNotationCerts(rootDir, "notation-upload-test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "notation-upload-test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + err = signatures.InitNotationDir(rootDir) + So(err, ShouldBeNil) + + notationDir, err := signatures.GetNotationDirPath() + So(err, ShouldBeNil) + + trustpolicyDoc, err := signatures.LoadTrustPolicyDocument(notationDir) + So(err, ShouldBeNil) + + trustpolicyDoc.TrustPolicies[0].TrustStores = append(trustpolicyDoc.TrustPolicies[0].TrustStores, + "ca:notation-upload-test") + + trustpolicyDocContent, err := json.Marshal(trustpolicyDoc) + So(err, ShouldBeNil) + + err = os.WriteFile(path.Join(notationDir, "trustpolicy.json"), trustpolicyDocContent, 0o400) + So(err, ShouldBeNil) + + err = signatures.UploadCertificate(certificateContent, "ca", "notation-upload-test") + So(err, ShouldBeNil) + }) + + Convey("upload certificate successfully", t, func() { + rootDir := t.TempDir() + + test.NotationPathLock.Lock() + defer test.NotationPathLock.Unlock() + + test.LoadNotationPath(rootDir) + + // generate a keypair + err := test.GenerateNotationCerts(rootDir, "notation-upload-test") + So(err, ShouldBeNil) + + certificateContent, err := os.ReadFile(path.Join(rootDir, "notation/localkeys", "notation-upload-test.crt")) + So(err, ShouldBeNil) + So(certificateContent, ShouldNotBeNil) + + err = signatures.InitNotationDir(rootDir) + So(err, ShouldBeNil) + + err = signatures.UploadCertificate(certificateContent, "ca", "notation-upload-test") + So(err, ShouldBeNil) + }) +} diff --git a/swagger/docs.go b/swagger/docs.go index 672a42a4..a335f883 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -151,6 +151,17 @@ const docTemplate = `{ "application/json" ], "summary": "Get current server configuration", + "parameters": [ + { + "enum": [ + "config" + ], + "type": "string", + "description": "specify resource", + "name": "resource", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -165,6 +176,80 @@ const docTemplate = `{ } } } + }, + "post": { + "description": "Upload certificates and public keys for verifying signatures", + "consumes": [ + "application/octet-stream" + ], + "produces": [ + "application/json" + ], + "summary": "Upload certificates and public keys for verifying signatures", + "parameters": [ + { + "enum": [ + "signatures" + ], + "type": "string", + "description": "specify resource", + "name": "resource", + "in": "query", + "required": true + }, + { + "enum": [ + "cosign", + "notation" + ], + "type": "string", + "description": "specify signing tool", + "name": "tool", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "truststore type", + "name": "truststoreType", + "in": "query" + }, + { + "type": "string", + "description": "truststore name", + "name": "truststoreName", + "in": "query" + }, + { + "description": "Public key or Certificate content", + "name": "requestBody", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request\".", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error\".", + "schema": { + "type": "string" + } + } + } } }, "/v2/_zot/ext/userprefs": { diff --git a/swagger/swagger.json b/swagger/swagger.json index c24fa253..7ef9a0f4 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -142,6 +142,17 @@ "application/json" ], "summary": "Get current server configuration", + "parameters": [ + { + "enum": [ + "config" + ], + "type": "string", + "description": "specify resource", + "name": "resource", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -156,6 +167,80 @@ } } } + }, + "post": { + "description": "Upload certificates and public keys for verifying signatures", + "consumes": [ + "application/octet-stream" + ], + "produces": [ + "application/json" + ], + "summary": "Upload certificates and public keys for verifying signatures", + "parameters": [ + { + "enum": [ + "signatures" + ], + "type": "string", + "description": "specify resource", + "name": "resource", + "in": "query", + "required": true + }, + { + "enum": [ + "cosign", + "notation" + ], + "type": "string", + "description": "specify signing tool", + "name": "tool", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "truststore type", + "name": "truststoreType", + "in": "query" + }, + { + "type": "string", + "description": "truststore name", + "name": "truststoreName", + "in": "query" + }, + { + "description": "Public key or Certificate content", + "name": "requestBody", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request\".", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error\".", + "schema": { + "type": "string" + } + } + } } }, "/v2/_zot/ext/userprefs": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 0226e71d..0d4bfe6e 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -291,6 +291,13 @@ paths: consumes: - application/json description: Get current server configuration + parameters: + - description: specify resource + enum: + - config + in: query + name: resource + type: string produces: - application/json responses: @@ -303,6 +310,56 @@ paths: schema: type: string summary: Get current server configuration + post: + consumes: + - application/octet-stream + description: Upload certificates and public keys for verifying signatures + parameters: + - description: specify resource + enum: + - signatures + in: query + name: resource + required: true + type: string + - description: specify signing tool + enum: + - cosign + - notation + in: query + name: tool + required: true + type: string + - description: truststore type + in: query + name: truststoreType + type: string + - description: truststore name + in: query + name: truststoreName + type: string + - description: Public key or Certificate content + in: body + name: requestBody + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: ok + schema: + type: string + "400": + description: bad request". + schema: + type: string + "500": + description: internal server error". + schema: + type: string + summary: Upload certificates and public keys for verifying signatures /v2/_zot/ext/userprefs: put: consumes: