Repository: osbuild/osbuild-composer
Pull request #3230 (2023-01-20 19:25:50)

Adding metadata signing check
Add support for checking repomd.xml gpg signature to sources.

This pull request includes:

- [X] adequate testing for the new functionality or fixed issue
- [X] adequate documentation informing people about the change such as
  - [X] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] QE has approved reproducer/new tests and
- [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
- [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`

Information regarding our GitLab pipeline (Schutzbot):

CI will not be ran automatically if WIP label is applied or the PR is in DRAFT state, instead only
a link will be provided to the pipeline which can then be triggered manually if desired. To run the
CI automatically either switch the PR to ready or apply WIP+test label.

Outside contributors need manual approval from one of the osbuild-composer maintainers.

Schutzbot will only be triggered if all Tests jobs in GitHub workflow succeed.
-->



Patch:
diff --git a/cmd/gen-manifests/main.go b/cmd/gen-manifests/main.go
index 814ad8c3d..dca6175a5 100644
--- a/cmd/gen-manifests/main.go
+++ b/cmd/gen-manifests/main.go
@@ -187,12 +187,17 @@ func makeManifestJob(name string, imgType distro.ImageType, cr composeRequest, d
 type DistroArchRepoMap map[string]map[string][]repository
 
 func convertRepo(r repository) rpmmd.RepoConfig {
+	var keys []string
+	if r.GPGKey != "" {
+		keys = []string{r.GPGKey}
+	}
+
 	return rpmmd.RepoConfig{
 		Name:           r.Name,
 		BaseURL:        r.BaseURL,
 		Metalink:       r.Metalink,
 		MirrorList:     r.MirrorList,
-		GPGKey:         r.GPGKey,
+		GPGKeys:        keys,
 		CheckGPG:       r.CheckGPG,
 		MetadataExpire: r.MetadataExpire,
 		ImageTypeTags:  r.ImageTypeTags,
diff --git a/cmd/osbuild-pipeline/main.go b/cmd/osbuild-pipeline/main.go
index e63000704..e0adcdcdc 100644
--- a/cmd/osbuild-pipeline/main.go
+++ b/cmd/osbuild-pipeline/main.go
@@ -140,12 +140,18 @@ func main() {
 		if repoName == "" {
 			repoName = fmt.Sprintf("repo-%d", i)
 		}
+
+		var keys []string
+		if repo.GPGKey != "" {
+			keys = []string{repo.GPGKey}
+		}
+
 		repos[i] = rpmmd.RepoConfig{
 			Name:        repoName,
 			BaseURL:     repo.BaseURL,
 			Metalink:    repo.Metalink,
 			MirrorList:  repo.MirrorList,
-			GPGKey:      repo.GPGKey,
+			GPGKeys:     keys,
 			CheckGPG:    repo.CheckGPG,
 			PackageSets: repo.PackageSets,
 		}
diff --git a/dnf-json b/dnf-json
index 5fef065cf..260b9723b 100755
--- a/dnf-json
+++ b/dnf-json
@@ -56,6 +56,7 @@ class Solver():
             self.base.repos.add(self._dnfrepo(repo, self.base.conf))
         self.base.fill_sack(load_system_repo=False)
 
+    # pylint: disable=too-many-branches
     @staticmethod
     def _dnfrepo(desc, parent_conf=None):
         """Makes a dnf.repo.Repo out of a JSON repository description"""
@@ -82,6 +83,32 @@ class Solver():
         if "sslclientcert" in desc:
             repo.sslclientcert = desc["sslclientcert"]
 
+        if "check_gpg" in desc:
+            repo.gpgcheck = desc["check_gpg"]
+        if "check_repogpg" in desc:
+            repo.repo_gpgcheck = desc["check_repogpg"]
+        if "gpgkey" in desc:
+            repo.gpgkey = [desc["gpgkey"]]
+        if "gpgkeys" in desc:
+            # gpgkeys can contain a full key, or it can be a URL
+            # dnf expects urls, so write the key to a temporary location and add the file://
+            # path to repo.gpgkey
+            keydir = os.path.join(parent_conf.persistdir, "gpgkeys")
+            if not os.path.exists(keydir):
+                os.makedirs(keydir, mode=0o700, exist_ok=True)
+
+            for key in desc["gpgkeys"]:
+                if key.startswith("-----BEGIN PGP PUBLIC KEY BLOCK-----"):
+                    # Not using with because it needs to be a valid file for the duration. It
+                    # is inside the temporary persistdir so will be cleaned up on exit.
+                    # pylint: disable=consider-using-with
+                    keyfile = tempfile.NamedTemporaryFile(dir=keydir, delete=False)
+                    keyfile.write(key.encode("utf-8"))
+                    repo.gpgkey.append(f"file://{keyfile.name}")
+                    keyfile.close()
+                else:
+                    repo.gpgkey.append(key)
+
         # In dnf, the default metadata expiration time is 48 hours. However,
         # some repositories never expire the metadata, and others expire it much
         # sooner than that. We therefore allow this to be configured. If nothing
diff --git a/internal/cloudapi/v2/handler.go b/internal/cloudapi/v2/handler.go
index 6208f45f2..c6b6e58cf 100644
--- a/internal/cloudapi/v2/handler.go
+++ b/internal/cloudapi/v2/handler.go
@@ -1255,14 +1255,14 @@ func genRepoConfig(repo Repository) (*rpmmd.RepoConfig, error) {
 	if repo.CheckGpg != nil {
 		repoConfig.CheckGPG = *repo.CheckGpg
 	}
-	if repo.Gpgkey != nil {
-		repoConfig.GPGKey = *repo.Gpgkey
+	if repo.Gpgkey != nil && *repo.Gpgkey != "" {
+		repoConfig.GPGKeys = []string{*repo.Gpgkey}
 	}
 	if repo.IgnoreSsl != nil {
 		repoConfig.IgnoreSSL = *repo.IgnoreSsl
 	}
 
-	if repoConfig.CheckGPG && repoConfig.GPGKey == "" {
+	if repoConfig.CheckGPG && len(repoConfig.GPGKeys) == 0 {
 		return nil, HTTPError(ErrorNoGPGKey)
 	}
 
diff --git a/internal/dnfjson/dnfjson.go b/internal/dnfjson/dnfjson.go
index fb9bfedda..653274fcc 100644
--- a/internal/dnfjson/dnfjson.go
+++ b/internal/dnfjson/dnfjson.go
@@ -247,7 +247,9 @@ func (s *Solver) reposFromRPMMD(rpmRepos []rpmmd.RepoConfig) ([]repoConfig, erro
 			BaseURL:        rr.BaseURL,
 			Metalink:       rr.Metalink,
 			MirrorList:     rr.MirrorList,
-			GPGKey:         rr.GPGKey,
+			GPGKeys:        rr.GPGKeys,
+			CheckGPG:       rr.CheckGPG,
+			CheckRepoGPG:   rr.CheckRepoGPG,
 			IgnoreSSL:      rr.IgnoreSSL,
 			MetadataExpire: rr.MetadataExpire,
 		}
@@ -271,17 +273,19 @@ func (s *Solver) reposFromRPMMD(rpmRepos []rpmmd.RepoConfig) ([]repoConfig, erro
 // Repository configuration for resolving dependencies for a set of packages. A
 // Solver needs at least one RPM repository configured to be able to depsolve.
 type repoConfig struct {
-	ID             string `json:"id"`
-	Name           string `json:"name,omitempty"`
-	BaseURL        string `json:"baseurl,omitempty"`
-	Metalink       string `json:"metalink,omitempty"`
-	MirrorList     string `json:"mirrorlist,omitempty"`
-	GPGKey         string `json:"gpgkey,omitempty"`
-	IgnoreSSL      bool   `json:"ignoressl"`
-	SSLCACert      string `json:"sslcacert,omitempty"`
-	SSLClientKey   string `json:"sslclientkey,omitempty"`
-	SSLClientCert  string `json:"sslclientcert,omitempty"`
-	MetadataExpire string `json:"metadata_expire,omitempty"`
+	ID             string   `json:"id"`
+	Name           string   `json:"name,omitempty"`
+	BaseURL        string   `json:"baseurl,omitempty"`
+	Metalink       string   `json:"metalink,omitempty"`
+	MirrorList     string   `json:"mirrorlist,omitempty"`
+	GPGKeys        []string `json:"gpgkeys,omitempty"`
+	CheckGPG       bool     `json:"gpgcheck"`
+	CheckRepoGPG   bool     `json:"check_repogpg"`
+	IgnoreSSL      bool     `json:"ignoressl"`
+	SSLCACert      string   `json:"sslcacert,omitempty"`
+	SSLClientKey   string   `json:"sslclientkey,omitempty"`
+	SSLClientCert  string   `json:"sslclientcert,omitempty"`
+	MetadataExpire string   `json:"metadata_expire,omitempty"`
 }
 
 // Hash calculates an ID string that uniquely represents a repository
@@ -292,7 +296,15 @@ func (r *repoConfig) Hash() string {
 	bts := func(b bool) string {
 		return fmt.Sprintf("%T", b)
 	}
-	return fmt.Sprintf("%x", sha256.Sum256([]byte(r.BaseURL+r.Metalink+r.MirrorList+r.GPGKey+bts(r.IgnoreSSL)+r.MetadataExpire)))
+	ats := func(s []string) string {
+		return strings.Join(s, "")
+	}
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(r.BaseURL+
+		r.Metalink+
+		r.MirrorList+
+		ats(r.GPGKeys)+
+		bts(r.IgnoreSSL)+
+		r.MetadataExpire)))
 }
 
 // Helper function for creating a depsolve request payload.
diff --git a/internal/osbuild/rpm_stage.go b/internal/osbuild/rpm_stage.go
index 7fbe138cd..ade1dea08 100644
--- a/internal/osbuild/rpm_stage.go
+++ b/internal/osbuild/rpm_stage.go
@@ -143,10 +143,10 @@ func pkgRefs(specs []rpmmd.PackageSpec) FilesInputRef {
 func NewRPMStageOptions(repos []rpmmd.RepoConfig) *RPMStageOptions {
 	var gpgKeys []string
 	for _, repo := range repos {
-		if repo.GPGKey == "" {
+		if len(repo.GPGKeys) == 0 {
 			continue
 		}
-		gpgKeys = append(gpgKeys, repo.GPGKey)
+		gpgKeys = append(gpgKeys, repo.GPGKeys...)
 	}
 
 	return &RPMStageOptions{
diff --git a/internal/rpmmd/repository.go b/internal/rpmmd/repository.go
index 6292e3821..5fc7ca470 100644
--- a/internal/rpmmd/repository.go
+++ b/internal/rpmmd/repository.go
@@ -33,8 +33,9 @@ type RepoConfig struct {
 	BaseURL        string
 	Metalink       string
 	MirrorList     string
-	GPGKey         string
+	GPGKeys        []string
 	CheckGPG       bool
+	CheckRepoGPG   bool
 	IgnoreSSL      bool
 	MetadataExpire string
 	RHSM           bool
@@ -49,7 +50,18 @@ func (r *RepoConfig) Hash() string {
 	bts := func(b bool) string {
 		return fmt.Sprintf("%T", b)
 	}
-	return fmt.Sprintf("%x", sha256.Sum256([]byte(r.BaseURL+r.Metalink+r.MirrorList+r.GPGKey+bts(r.CheckGPG)+bts(r.IgnoreSSL)+r.MetadataExpire+bts(r.RHSM))))
+	ats := func(s []string) string {
+		return strings.Join(s, "")
+	}
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(r.BaseURL+
+		r.Metalink+
+		r.MirrorList+
+		ats(r.GPGKeys)+
+		bts(r.CheckGPG)+
+		bts(r.CheckRepoGPG)+
+		bts(r.IgnoreSSL)+
+		r.MetadataExpire+
+		bts(r.RHSM))))
 }
 
 type DistrosRepoConfigs map[string]map[string][]RepoConfig
@@ -212,12 +224,17 @@ func loadRepositoriesFromFile(filename string) (map[string][]RepoConfig, error)
 
 	for arch, repos := range reposMap {
 		for _, repo := range repos {
+			var keys []string
+			if repo.GPGKey != "" {
+				keys = []string{repo.GPGKey}
+			}
+
 			config := RepoConfig{
 				Name:           repo.Name,
 				BaseURL:        repo.BaseURL,
 				Metalink:       repo.Metalink,
 				MirrorList:     repo.MirrorList,
-				GPGKey:         repo.GPGKey,
+				GPGKeys:        keys,
 				CheckGPG:       repo.CheckGPG,
 				RHSM:           repo.RHSM,
 				MetadataExpire: repo.MetadataExpire,
diff --git a/internal/store/json.go b/internal/store/json.go
index 844c1942e..3ee4a86da 100644
--- a/internal/store/json.go
+++ b/internal/store/json.go
@@ -58,14 +58,16 @@ type imageBuildV0 struct {
 }
 
 type sourceV0 struct {
-	Name     string   `json:"name"`
-	Type     string   `json:"type"`
-	URL      string   `json:"url"`
-	CheckGPG bool     `json:"check_gpg"`
-	CheckSSL bool     `json:"check_ssl"`
-	System   bool     `json:"system"`
-	Distros  []string `json:"distros"`
-	RHSM     bool     `json:"rhsm"`
+	Name         string   `json:"name"`
+	Type         string   `json:"type"`
+	URL          string   `json:"url"`
+	CheckGPG     bool     `json:"check_gpg"`
+	CheckSSL     bool     `json:"check_ssl"`
+	System       bool     `json:"system"`
+	Distros      []string `json:"distros"`
+	RHSM         bool     `json:"rhsm"`
+	CheckRepoGPG bool     `json:"check_repogpg"`
+	GPGKeys      []string `json:"gpgkeys"`
 }
 
 type sourcesV0 map[string]sourceV0
diff --git a/internal/store/store.go b/internal/store/store.go
index 71158e5b8..d3523d2c8 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -54,14 +54,16 @@ type Store struct {
 }
 
 type SourceConfig struct {
-	Name     string   `json:"name" toml:"name"`
-	Type     string   `json:"type" toml:"type"`
-	URL      string   `json:"url" toml:"url"`
-	CheckGPG bool     `json:"check_gpg" toml:"check_gpg"`
-	CheckSSL bool     `json:"check_ssl" toml:"check_ssl"`
-	System   bool     `json:"system" toml:"system"`
-	Distros  []string `json:"distros" toml:"distros"`
-	RHSM     bool     `json:"rhsm" toml:"rhsm"`
+	Name         string   `json:"name" toml:"name"`
+	Type         string   `json:"type" toml:"type"`
+	URL          string   `json:"url" toml:"url"`
+	CheckGPG     bool     `json:"check_gpg" toml:"check_gpg"`
+	CheckSSL     bool     `json:"check_ssl" toml:"check_ssl"`
+	System       bool     `json:"system" toml:"system"`
+	Distros      []string `json:"distros" toml:"distros"`
+	RHSM         bool     `json:"rhsm" toml:"rhsm"`
+	CheckRepoGPG bool     `json:"check_repogpg" toml:"check_repogpg"`
+	GPGKeys      []string `json:"gpgkeys"`
 }
 
 type NotFoundError struct {
@@ -578,11 +580,13 @@ func (s *Store) GetAllDistroSources(distro string) map[string]SourceConfig {
 
 func NewSourceConfig(repo rpmmd.RepoConfig, system bool) SourceConfig {
 	sc := SourceConfig{
-		Name:     repo.Name,
-		CheckGPG: repo.CheckGPG,
-		CheckSSL: !repo.IgnoreSSL,
-		System:   system,
-		RHSM:     repo.RHSM,
+		Name:         repo.Name,
+		CheckGPG:     repo.CheckGPG,
+		CheckSSL:     !repo.IgnoreSSL,
+		System:       system,
+		RHSM:         repo.RHSM,
+		CheckRepoGPG: repo.CheckRepoGPG,
+		GPGKeys:      repo.GPGKeys,
 	}
 
 	if repo.BaseURL != "" {
@@ -606,6 +610,8 @@ func (s *SourceConfig) RepoConfig(name string) rpmmd.RepoConfig {
 	repo.IgnoreSSL = !s.CheckSSL
 	repo.CheckGPG = s.CheckGPG
 	repo.RHSM = s.RHSM
+	repo.CheckRepoGPG = s.CheckRepoGPG
+	repo.GPGKeys = s.GPGKeys
 
 	if s.Type == "yum-baseurl" {
 		repo.BaseURL = s.URL
diff --git a/internal/weldr/json.go b/internal/weldr/json.go
index f4fc22edd..c7149b400 100644
--- a/internal/weldr/json.go
+++ b/internal/weldr/json.go
@@ -120,7 +120,7 @@ type SourceConfig interface {
 }
 
 // NewSourceConfigV0 converts a store.SourceConfig to a SourceConfigV0
-// The store does not support proxy and gpgkey_urls
+// The store does not support proxy and gpgkeys
 func NewSourceConfigV0(s store.SourceConfig) SourceConfigV0 {
 	var sc SourceConfigV0
 
@@ -143,7 +143,7 @@ type SourceConfigV0 struct {
 	CheckSSL bool     `json:"check_ssl" toml:"check_ssl"`
 	System   bool     `json:"system" toml:"system"`
 	Proxy    string   `json:"proxy,omitempty" toml:"proxy,omitempty"`
-	GPGUrls  []string `json:"gpgkey_urls,omitempty" toml:"gpgkey_urls,omitempty"`
+	GPGKeys  []string `json:"gpgkeys,omitempty" toml:"gpgkeys,omitempty"`
 }
 
 // Key return the key, .Name in this case
@@ -162,7 +162,7 @@ func (s SourceConfigV0) GetType() string {
 }
 
 // SourceConfig returns a SourceConfig struct populated with the supported variables
-// The store does not support proxy and gpgkey_urls
+// The store does not support proxy and gpgkeys
 func (s SourceConfigV0) SourceConfig() (ssc store.SourceConfig) {
 	ssc.Name = s.Name
 	ssc.Type = s.Type
@@ -180,7 +180,7 @@ type SourceInfoResponseV0 struct {
 }
 
 // NewSourceConfigV1 converts a store.SourceConfig to a SourceConfigV1
-// The store does not support proxy and gpgkey_urls
+// The store does not support proxy and gpgkeys
 func NewSourceConfigV1(id string, s store.SourceConfig) SourceConfigV1 {
 	var sc SourceConfigV1
 
@@ -193,23 +193,26 @@ func NewSourceConfigV1(id string, s store.SourceConfig) SourceConfigV1 {
 	sc.System = s.System
 	sc.Distros = s.Distros
 	sc.RHSM = s.RHSM
+	sc.CheckRepoGPG = s.CheckRepoGPG
+	sc.GPGKeys = s.GPGKeys
 
 	return sc
 }
 
 // SourceConfigV1 holds the source repository information
 type SourceConfigV1 struct {
-	ID       string   `json:"id" toml:"id"`
-	Name     string   `json:"name" toml:"name"`
-	Type     string   `json:"type" toml:"type"`
-	URL      string   `json:"url" toml:"url"`
-	CheckGPG bool     `json:"check_gpg" toml:"check_gpg"`
-	CheckSSL bool     `json:"check_ssl" toml:"check_ssl"`
-	System   bool     `json:"system" toml:"system"`
-	Proxy    string   `json:"proxy,omitempty" toml:"proxy,omitempty"`
-	GPGUrls  []string `json:"gpgkey_urls,omitempty" toml:"gpgkey_urls,omitempty"`
-	Distros  []string `json:"distros,omitempty" toml:"distros,omitempty"`
-	RHSM     bool     `json:"rhsm" toml:"rhsm"`
+	ID           string   `json:"id" toml:"id"`
+	Name         string   `json:"name" toml:"name"`
+	Type         string   `json:"type" toml:"type"`
+	URL          string   `json:"url" toml:"url"`
+	CheckGPG     bool     `json:"check_gpg" toml:"check_gpg"`
+	CheckSSL     bool     `json:"check_ssl" toml:"check_ssl"`
+	System       bool     `json:"system" toml:"system"`
+	Proxy        string   `json:"proxy,omitempty" toml:"proxy,omitempty"`
+	GPGKeys      []string `json:"gpgkeys,omitempty" toml:"gpgkeys,omitempty"`
+	Distros      []string `json:"distros,omitempty" toml:"distros,omitempty"`
+	RHSM         bool     `json:"rhsm" toml:"rhsm"`
+	CheckRepoGPG bool     `json:"check_repogpg" toml:"check_repogpg"`
 }
 
 // Key returns the key, .ID in this case
@@ -228,7 +231,7 @@ func (s SourceConfigV1) GetType() string {
 }
 
 // SourceConfig returns a SourceConfig struct populated with the supported variables
-// The store does not support proxy and gpgkey_urls
+// The store does not support proxy and gpgkeys
 func (s SourceConfigV1) SourceConfig() (ssc store.SourceConfig) {
 	ssc.Name = s.Name
 	ssc.Type = s.Type
@@ -237,6 +240,8 @@ func (s SourceConfigV1) SourceConfig() (ssc store.SourceConfig) {
 	ssc.CheckSSL = s.CheckSSL
 	ssc.Distros = s.Distros
 	ssc.RHSM = s.RHSM
+	ssc.CheckRepoGPG = s.CheckRepoGPG
+	ssc.GPGKeys = s.GPGKeys
 
 	return ssc
 }


Test patch:
diff --git a/cmd/osbuild-composer-cli-tests/main_test.go b/cmd/osbuild-composer-cli-tests/main_test.go
index 563784189..5946fc1e5 100644
--- a/cmd/osbuild-composer-cli-tests/main_test.go
+++ b/cmd/osbuild-composer-cli-tests/main_test.go
@@ -1,4 +1,4 @@
-// +build integration
+//go:build integration
 
 package main
 
@@ -171,7 +171,7 @@ type = "yum-baseurl"
 proxy = "https://proxy-url/"
 check_ssl = true
 check_gpg = true
-gpgkey_urls = ["https://url/path/to/gpg-key"]
+gpgkeys = ["https://url/path/to/gpg-key"]
 `))
 	require.NoError(t, err)
 
diff --git a/internal/client/source_test.go b/internal/client/source_test.go
index 516f29161..df0db0314 100644
--- a/internal/client/source_test.go
+++ b/internal/client/source_test.go
@@ -25,7 +25,7 @@ func TestPOSTJSONSourceV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
 
@@ -62,7 +62,7 @@ func TestPOSTInvalidJSONSourceV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`
 
 	resp, err := PostJSONSourceV0(testState.socket, source)
@@ -81,7 +81,7 @@ func TestPOSTInvalidJSONSourceV1(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`
 
 	resp, err := PostJSONSourceV1(testState.socket, source)
@@ -103,7 +103,7 @@ func TestPOSTSystemJSONSourceV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`
 
 	for _, repoName := range []string{"test-system-repo", "fedora", "baseos"} {
@@ -137,7 +137,7 @@ func TestPOSTSystemJSONSourceV1(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`
 
 	for _, repoName := range []string{"test-system-repo", "fedora", "baseos"} {
@@ -165,7 +165,7 @@ func TestPOSTTOMLSourceV0(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
 
@@ -188,7 +188,7 @@ func TestPOSTTOMLSourceV1(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
 
@@ -225,7 +225,7 @@ func TestPOSTInvalidTOMLSourceV0(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 
 	resp, err := PostTOMLSourceV0(testState.socket, source)
@@ -244,7 +244,7 @@ func TestPOSTInvalidTOMLSourceV1(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 
 	resp, err := PostTOMLSourceV1(testState.socket, source)
@@ -263,7 +263,7 @@ func TestPOSTWrongTOMLSourceV0(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 
 	resp, err := PostTOMLSourceV0(testState.socket, source)
@@ -283,7 +283,7 @@ func TestPOSTWrongTOMLSourceV1(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 
 	resp, err := PostTOMLSourceV1(testState.socket, source)
@@ -304,7 +304,7 @@ func TestPOSTTOMLSystemSourceV0(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
 	for _, repoName := range []string{"test-system-repo", "fedora", "baseos"} {
@@ -337,7 +337,7 @@ func TestPOSTTOMLSystemSourceV1(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
 	for _, repoName := range []string{"test-system-repo", "fedora", "baseos"} {
@@ -365,7 +365,7 @@ func TestListSourcesV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`,
 		`{
 		"name": "package-repo-2",
@@ -374,7 +374,7 @@ func TestListSourcesV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`}
 
 	for i := range sources {
@@ -412,7 +412,7 @@ func TestListSourcesV1(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`,
 		`{
 		"id": "package-repo-2",
@@ -422,7 +422,7 @@ func TestListSourcesV1(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`}
 
 	for i := range sources {
@@ -459,7 +459,7 @@ func TestGetSourceInfoV0(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 	`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
 
@@ -489,7 +489,7 @@ func TestGetSourceInfoV1(t *testing.T) {
 		proxy = "https://proxy-url/"
 		check_ssl = true
 		check_gpg = true
-		gpgkey_urls = ["https://url/path/to/gpg-key"]
+		gpgkeys = ["https://url/path/to/gpg-key"]
 		rhsm = false
 	`
 	source = strings.Replace(source, "REPO-PATH", testState.repoDir, 1)
@@ -560,7 +560,7 @@ func TestDeleteUserDefinedSourcesV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`,
 		`{
 		"name": "package-repo-2",
@@ -569,7 +569,7 @@ func TestDeleteUserDefinedSourcesV0(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`}
 
 	// verify test starts without user defined sources
@@ -601,7 +601,7 @@ func TestDeleteUserDefinedSourcesV1(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`,
 		`{
 		"id": "package-repo-2",
@@ -611,7 +611,7 @@ func TestDeleteUserDefinedSourcesV1(t *testing.T) {
 		"proxy": "https://proxy-url/",
 		"check_ssl": true,
 		"check_gpg": true,
-		"gpgkey_urls": ["https://url/path/to/gpg-key"]
+		"gpgkeys": ["https://url/path/to/gpg-key"]
 	}`}
 
 	// verify test starts without user defined sources
diff --git a/internal/cloudapi/v2/v2_internal_test.go b/internal/cloudapi/v2/v2_internal_test.go
index 0b600c439..e18280c9a 100644
--- a/internal/cloudapi/v2/v2_internal_test.go
+++ b/internal/cloudapi/v2/v2_internal_test.go
@@ -107,7 +107,7 @@ func TestRepoConfigConversion(t *testing.T) {
 				BaseURL:        "http://base.url",
 				Metalink:       "",
 				MirrorList:     "",
-				GPGKey:         "some-kind-of-key",
+				GPGKeys:        []string{"some-kind-of-key"},
 				CheckGPG:       true,
 				IgnoreSSL:      false,
 				MetadataExpire: "",
@@ -131,7 +131,6 @@ func TestRepoConfigConversion(t *testing.T) {
 				BaseURL:        "http://base.url",
 				Metalink:       "", // since BaseURL is specified, MetaLink is not copied
 				MirrorList:     "", // since BaseURL is specified, MirrorList is not copied
-				GPGKey:         "",
 				CheckGPG:       false,
 				IgnoreSSL:      true,
 				MetadataExpire: "",
@@ -155,7 +154,6 @@ func TestRepoConfigConversion(t *testing.T) {
 				BaseURL:        "",
 				Metalink:       "", // since MirrorList is specified, MetaLink is not copied
 				MirrorList:     "http://example.org/mirrorlist",
-				GPGKey:         "",
 				CheckGPG:       false,
 				IgnoreSSL:      true,
 				MetadataExpire: "",
@@ -179,7 +177,6 @@ func TestRepoConfigConversion(t *testing.T) {
 				BaseURL:        "",
 				Metalink:       "http://example.org/metalink",
 				MirrorList:     "",
-				GPGKey:         "",
 				CheckGPG:       false,
 				IgnoreSSL:      true,
 				MetadataExpire: "",
diff --git a/internal/distro/distro_test_common/distro_test_common.go b/internal/distro/distro_test_common/distro_test_common.go
index 042edca61..2941d81e7 100644
--- a/internal/distro/distro_test_common/distro_test_common.go
+++ b/internal/distro/distro_test_common/distro_test_common.go
@@ -61,12 +61,17 @@ func TestDistro_Manifest(t *testing.T, pipelinePath string, prefix string, regis
 
 		repos := make([]rpmmd.RepoConfig, len(tt.ComposeRequest.Repositories))
 		for i, repo := range tt.ComposeRequest.Repositories {
+			var keys []string
+			if repo.GPGKey != "" {
+				keys = []string{repo.GPGKey}
+			}
+
 			repos[i] = rpmmd.RepoConfig{
 				Name:       fmt.Sprintf("repo-%d", i),
 				BaseURL:    repo.BaseURL,
 				Metalink:   repo.Metalink,
 				MirrorList: repo.MirrorList,
-				GPGKey:     repo.GPGKey,
+				GPGKeys:    keys,
 				CheckGPG:   repo.CheckGPG,
 			}
 		}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 16f9e30a6..2cb0e6b65 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -450,8 +450,20 @@ func (suite *storeTest) TestNewSourceConfigWithMirrorList() {
 	suite.Equal(expectedSource, actualSource)
 }
 
+// Test converting a SourceConfig with GPGkeys to a RepoConfig
+func (suite *storeTest) TestRepoConfigGPGKeys() {
+	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "testURL", Metalink: "", MirrorList: "", IgnoreSSL: true, MetadataExpire: "", CheckRepoGPG: true, GPGKeys: []string{"http://path.to.gpgkeys/key.pub", "-----BEGIN PGP PUBLIC KEY BLOCK-----\nFULL GPG KEY HERE\n-----END PGP PUBLIC KEY BLOCK-----"}}
+	mySourceConfig := suite.mySourceConfig
+	mySourceConfig.Type = "yum-baseurl"
+	mySourceConfig.URL = "testURL"
+	mySourceConfig.CheckRepoGPG = true
+	mySourceConfig.GPGKeys = []string{"http://path.to.gpgkeys/key.pub", "-----BEGIN PGP PUBLIC KEY BLOCK-----\nFULL GPG KEY HERE\n-----END PGP PUBLIC KEY BLOCK-----"}
+	actualRepo := mySourceConfig.RepoConfig("testSourceConfig")
+	suite.Equal(expectedRepo, actualRepo)
+}
+
 func (suite *storeTest) TestRepoConfigBaseURL() {
-	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "testURL", Metalink: "", MirrorList: "", GPGKey: "", IgnoreSSL: true, MetadataExpire: ""}
+	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "testURL", Metalink: "", MirrorList: "", IgnoreSSL: true, MetadataExpire: ""}
 	suite.mySourceConfig.Type = "yum-baseurl"
 	suite.mySourceConfig.URL = "testURL"
 	actualRepo := suite.mySourceConfig.RepoConfig("testSourceConfig")
@@ -459,7 +471,7 @@ func (suite *storeTest) TestRepoConfigBaseURL() {
 }
 
 func (suite *storeTest) TestRepoConfigMetalink() {
-	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "", Metalink: "testURL", MirrorList: "", GPGKey: "", IgnoreSSL: true, MetadataExpire: ""}
+	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "", Metalink: "testURL", MirrorList: "", IgnoreSSL: true, MetadataExpire: ""}
 	suite.mySourceConfig.Type = "yum-metalink"
 	suite.mySourceConfig.URL = "testURL"
 	actualRepo := suite.mySourceConfig.RepoConfig("testSourceConfig")
@@ -467,12 +479,13 @@ func (suite *storeTest) TestRepoConfigMetalink() {
 }
 
 func (suite *storeTest) TestRepoConfigMirrorlist() {
-	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "", Metalink: "", MirrorList: "testURL", GPGKey: "", IgnoreSSL: true, MetadataExpire: ""}
+	expectedRepo := rpmmd.RepoConfig{Name: "testSourceConfig", BaseURL: "", Metalink: "", MirrorList: "testURL", IgnoreSSL: true, MetadataExpire: ""}
 	suite.mySourceConfig.Type = "yum-mirrorlist"
 	suite.mySourceConfig.URL = "testURL"
 	actualRepo := suite.mySourceConfig.RepoConfig("testSourceConfig")
 	suite.Equal(expectedRepo, actualRepo)
 }
+
 func TestStore(t *testing.T) {
 	suite.Run(t, new(storeTest))
 }
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index 01978690c..83d271b1d 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -1549,6 +1549,7 @@ func TestSourcesNew(t *testing.T) {
 		{"POST", "/api/v1/projects/source/new", `{"id": "test-id", "name": "test system repo", "url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": false}`, http.StatusBadRequest, `{"errors": [{"id": "SystemSource","msg": "test-id is a system source, it cannot be changed."}],"status":false}`},
 		{"POST", "/api/v1/projects/source/new", `{"id": "fish","name":"fish repo","url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": false,"distros":["test-distro", "test-distro-2"]}`, http.StatusOK, `{"status":true}`},
 		{"POST", "/api/v1/projects/source/new", `{"id": "fish","name":"fish repo","url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": false,"distros":["fedora-1"]}`, http.StatusBadRequest, `{"status":false, "errors":[{"id":"ProjectsError", "msg":"Invalid distributions: fedora-1"}]}`},
+		{"POST", "/api/v1/projects/source/new", `{"id": "fish","name":"fish repo","url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": true,"check_repogpg":true,"gpgkeys": ["https://repourl/path/to/key.pub"]}`, http.StatusOK, `{"status":true}`},
 	}
 
 	tempdir := t.TempDir()
@@ -1638,6 +1639,45 @@ url = "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedor
 type = "yum-baseurl"
 check_ssl = false
 check_gpg = false
+`, `id = "fish"
+name = "fish or cut bait"
+url = "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/"
+type = "yum-baseurl"
+check_ssl = false
+check_gpg = true
+check_repogpg = true
+gpgkeys = ['''-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+mQENBEt+xXMBCACkA1ZtcO4H7ZUG/0aL4RlZIozsorXzFrrTAsJEHvdy+rHCH3xR
+cFz6IMbfCOdV+oKxlDP7PS0vWKfqxwkenOUut5o9b32uDdFMW4IbFXEQ94AuSQpS
+jo8PlVMm/51pmmRxdJzyPnr0YD38mVK6qUEYLI/4zXSgFk493GT8Y4m3N18O/+ye
+PnOOItj7qbrCMASoBx1TG8Zdg8ufehMnfb85x4xxAebXkqJQpEVTjt4lj4p6BhrW
+R+pIW/nBUrz3OsV7WwPKjSLjJtTJFxYX+RFSCqOdfusuysoOxpIHOx1WxjGUOB5j
+fnhmq41nWXf8ozb58zSpjDrJ7jGQ9pdUpAtRABEBAAG0HkJyaWFuIEMuIExhbmUg
+PGJjbEByZWRoYXQuY29tPokBOAQTAQIAIgUCS37FcwIbAwYLCQgHAwIGFQgCCQoL
+BBYCAwECHgECF4AACgkQEX6MFo7+On9dgAf9Hi2K1MKcmLkDeSUIXkXIAw0nAzl2
+UDGLWEdDqAgFxP6UaCVtOIRCr7z4EDOQoxD7mkdekbH2W5GcTO4h8MQBHYD9EkY7
+H/lTKchlFfsmafOoA3Y/tDLPKu+OIfH9Mqn2Mf7wMYGrnWSRNKYgvC5zkMgkhoPU
+mSPPHyBabsdS/Kg5ZAf43ac/MXY9V8Mk6zqbBlj6QYqjJ0nBD6vwozrDQ5gJtDUL
+mQho13zPn4lBJl9YJVjcgRB2WbzgSZOln0DfV22Seai66vnr5NyaOIw5B9QLSNhN
+EaPFswEDLKCsns9dkDuGFX52/Mt/i7JySvwhMBqHElPzWmwCHeY45M8gBYhGBBAR
+AgAGBQJLfsbpAAoJECH7Y/6XEsLNuasAn0Q0jB4Ea/95EREUkCFTm9L6nOpAAJ9t
+QzwGXhrLFZzOdRWYiWcCQbX5/7kBDQRLfsVzAQgAvN5jr95pJthv2w9co9/7omhM
+5rAnr9WJfbMLLiUfPPUvpL24RGO6SKy03aiVTUjlaHc+cGqOciwnNKMCSt+noyG2
+kNnAESTDtCivpsjonaFP8jA3TqL0QK+yzBRKJnMnLEY1nWE1FtkMRccXvzi0Z/XQ
+VhiWQyTvDFoKtepBFrH9UqWbNHyki22aighumUsW01pcPH2ogSj+HR01r7SfI/y2
+EkE6loHQfCDycHmlqYV+X6GZEvf1qu2+EHEQChsHIAxWyshsxM/ZPmx/8e5S3Xmj
+l7h/6E9wcsIpvnf504sLX5j4Km9I5HgJSRxHxgRPpqJ2/XiClAJanO5gCw0RdQAR
+AQABiQEfBBgBAgAJBQJLfsVzAhsMAAoJEBF+jBaO/jp/SqEH/iArzrfVOhZQGuy1
+KmG0+/FdJGqAEHP5HWpsaeYJok1VmhTPZd4IVFBz/bGJYyvsrPU0pJ6QLkdGxNnb
+KulJocgkW5MKEL/CRc54ESKwYngigmbY4qLwhS+gB3BJg1TvoHD810MSj4wdxNNo
+6JQmFmuoDsLRwaRYbKQDz95XXoGQtmV1o57T05WkLuC5OmHqnWv3rggVC8madpUJ
+moUUvUWgU1qyXe3PrgMGFOibWIl7lPZ08nzKXBRvSK/xoTGxl+570AevfVHMu5Uk
+Yu2U6D6/DYohtTYp0s1ekS5KQkCJM7lfqecDsQhfVfOfR0w4aF8k8u3HmWdOfUz+
+9+2ZsBo=
+=myjM
+-----END PGP PUBLIC KEY BLOCK-----''']
 `}
 	for _, source := range sources {
 		req := httptest.NewRequest("POST", "/api/v1/projects/source/new", bytes.NewReader([]byte(source)))
@@ -1663,7 +1703,7 @@ type = "yum-baseurl"
 rhsm = true
 `
 
-	sourceStr := `{"check_gpg":false,"check_ssl":false,"id":"fish","name":"fish","rhsm":true,"system":false,"type":"yum-baseurl","url":"https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/"}`
+	sourceStr := `{"check_gpg":false,"check_repogpg":false,"check_ssl":false,"id":"fish","name":"fish","rhsm":true,"system":false,"type":"yum-baseurl","url":"https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/"}`
 
 	req := httptest.NewRequest("POST", "/api/v1/projects/source/new", bytes.NewReader([]byte(source)))
 	req.Header.Set("Content-Type", "text/x-toml")
@@ -1678,6 +1718,15 @@ rhsm = true
 	test.TestRoute(t, api, true, "GET", "/api/v1/projects/source/info/fish?format=json", ``, 200, `{"sources":{"fish":`+sourceStr+`},"errors":[]}`)
 }
 
+func TestSourcesInfoGPGKeysV1(t *testing.T) {
+	sourceStr := `{"id":"fish","name":"fish repo","type":"yum-baseurl","url":"https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","check_gpg":true,"check_repogpg":true,"check_ssl":false,"gpgkeys":["https://repourl/path/to/key.pub"],"rhsm":false,"system":false}`
+
+	api, _ := createWeldrAPI(t.TempDir(), rpmmd_mock.BaseFixture)
+	test.SendHTTP(api, true, "POST", "/api/v1/projects/source/new", sourceStr)
+	test.TestRoute(t, api, true, "GET", "/api/v1/projects/source/info/fish", ``, 200, `{"sources":{"fish":`+sourceStr+`},"errors":[]}`)
+	test.TestRoute(t, api, true, "GET", "/api/v1/projects/source/info/fish?format=json", ``, 200, `{"sources":{"fish":`+sourceStr+`},"errors":[]}`)
+}
+
 // TestSourcesNewWrongTomlV1 Tests that Empty TOML, and invalid TOML should return an error
 func TestSourcesNewWrongTomlV1(t *testing.T) {
 	tempdir := t.TempDir()