diff --git a/integration/copy_test.go b/integration/copy_test.go index f7a92dd5..bd4c3289 100644 --- a/integration/copy_test.go +++ b/integration/copy_test.go @@ -5,6 +5,7 @@ import ( "io/ioutil" "os" "path/filepath" + "strings" "github.com/containers/image/manifest" "github.com/go-check/check" @@ -16,6 +17,7 @@ func init() { type CopySuite struct { cluster *openshiftCluster + gpgHome string } func (s *CopySuite) SetUpSuite(c *check.C) { @@ -25,7 +27,7 @@ func (s *CopySuite) SetUpSuite(c *check.C) { s.cluster = startOpenshiftCluster(c) - for _, stream := range []string{"unsigned"} { + for _, stream := range []string{"unsigned", "personal", "official", "naming", "cosigned"} { isJSON := fmt.Sprintf(`{ "kind": "ImageStream", "apiVersion": "v1", @@ -36,14 +38,53 @@ func (s *CopySuite) SetUpSuite(c *check.C) { }`, stream) runCommandWithInput(c, isJSON, "oc", "create", "-f", "-") } + + gpgHome, err := ioutil.TempDir("", "skopeo-gpg") + c.Assert(err, check.IsNil) + s.gpgHome = gpgHome + os.Setenv("GNUPGHOME", s.gpgHome) + + for _, key := range []string{"personal", "official"} { + batchInput := fmt.Sprintf("Key-Type: RSA\nName-Real: Test key - %s\nName-email: %s@example.com\n%%commit\n", + key, key) + runCommandWithInput(c, batchInput, gpgBinary, "--batch", "--gen-key") + + out := combinedOutputOfCommand(c, gpgBinary, "--armor", "--export", fmt.Sprintf("%s@example.com", key)) + err := ioutil.WriteFile(filepath.Join(s.gpgHome, fmt.Sprintf("%s-pubkey.gpg", key)), + []byte(out), 0600) + c.Assert(err, check.IsNil) + } } func (s *CopySuite) TearDownSuite(c *check.C) { + if s.gpgHome != "" { + os.RemoveAll(s.gpgHome) + } if s.cluster != nil { s.cluster.tearDown() } } +// preparePolicyFixture applies edits to fixtures/policy.json and returns a path to the temporary file. +// Callers should defer os.Remove(the_returned_path) +func preparePolicyFixture(c *check.C, edits map[string]string) string { + commands := []string{} + for template, value := range edits { + commands = append(commands, fmt.Sprintf("s,%s,%s,g", template, value)) + } + json := combinedOutputOfCommand(c, "sed", strings.Join(commands, "; "), "fixtures/policy.json") + + file, err := ioutil.TempFile("", "policy.json") + c.Assert(err, check.IsNil) + path := file.Name() + + _, err = file.Write([]byte(json)) + c.Assert(err, check.IsNil) + err = file.Close() + c.Assert(err, check.IsNil) + return path +} + // The most basic (skopeo copy) use: func (s *CopySuite) TestCopySimple(c *check.C) { dir1, err := ioutil.TempDir("", "copy-1") @@ -109,3 +150,95 @@ func (s *CopySuite) TestCopyStreaming(c *check.C) { c.Assert(out, check.Equals, "") // FIXME: Also check pushing to docker:// } + +// --sign-by and --policy copy, primarily using atomic: +func (s *CopySuite) TestCopySignatures(c *check.C) { + dir, err := ioutil.TempDir("", "signatures-dest") + c.Assert(err, check.IsNil) + defer os.RemoveAll(dir) + dirDest := "dir:" + dir + + policy := preparePolicyFixture(c, map[string]string{"@keydir@": s.gpgHome}) + defer os.Remove(policy) + + // type: reject + assertSkopeoFails(c, ".*Source image rejected: Running image docker://busybox:latest is rejected by policy.*", + "--policy", policy, "copy", "docker://busybox:latest", dirDest) + + // type: insecureAcceptAnything + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "docker://openshift/hello-openshift", dirDest) + + // type: signedBy + // Sign the images + assertSkopeoSucceeds(c, "", "copy", "--sign-by", "personal@example.com", "docker://busybox:1.23", "atomic:myns/personal:personal") + assertSkopeoSucceeds(c, "", "copy", "--sign-by", "official@example.com", "docker://busybox:1.23.2", "atomic:myns/official:official") + // Verify that we can pull them + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "atomic:myns/personal:personal", dirDest) + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "atomic:myns/official:official", dirDest) + // Verify that mis-signed images are rejected + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/personal:personal", "atomic:myns/official:attack") + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/official:official", "atomic:myns/personal:attack") + assertSkopeoFails(c, ".*Source image rejected: Invalid GPG signature.*", + "--policy", policy, "copy", "atomic:myns/personal:attack", dirDest) + assertSkopeoFails(c, ".*Source image rejected: Invalid GPG signature.*", + "--policy", policy, "copy", "atomic:myns/official:attack", dirDest) + + // Verify that signed identity is verified. + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/official:official", "atomic:myns/naming:test1") + assertSkopeoFails(c, ".*Source image rejected: Signature for identity localhost:8443/myns/official:official is not accepted.*", + "--policy", policy, "copy", "atomic:myns/naming:test1", dirDest) + // signedIdentity works + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/official:official", "atomic:myns/naming:naming") + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "atomic:myns/naming:naming", dirDest) + + // Verify that cosigning requirements are enforced + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/official:official", "atomic:myns/cosigned:cosigned") + assertSkopeoFails(c, ".*Source image rejected: Invalid GPG signature.*", + "--policy", policy, "copy", "atomic:myns/cosigned:cosigned", dirDest) + + assertSkopeoSucceeds(c, "", "copy", "--sign-by", "personal@example.com", "atomic:myns/official:official", "atomic:myns/cosigned:cosigned") + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "atomic:myns/cosigned:cosigned", dirDest) +} + +// --policy copy for dir: sources +func (s *CopySuite) TestCopyDirSignatures(c *check.C) { + topDir, err := ioutil.TempDir("", "dir-signatures-top") + c.Assert(err, check.IsNil) + defer os.RemoveAll(topDir) + topDirDest := "dir:" + topDir + + for _, suffix := range []string{"/dir1", "/dir2", "/restricted/personal", "/restricted/official", "/restricted/badidentity", "/dest"} { + err := os.MkdirAll(topDir+suffix, 0755) + c.Assert(err, check.IsNil) + } + + // Note the "/@dirpath@": The value starts with a slash so that it is not rejected in other tests which do not replace it, + // but we must ensure that the result is a canonical path, not something starting with a "//". + policy := preparePolicyFixture(c, map[string]string{"@keydir@": s.gpgHome, "/@dirpath@": topDir + "/restricted"}) + defer os.Remove(policy) + + // Get some images. + assertSkopeoSucceeds(c, "", "copy", "docker://estesp/busybox:armfh", topDirDest+"/dir1") + assertSkopeoSucceeds(c, "", "copy", "docker://estesp/busybox:s390x", topDirDest+"/dir2") + + // Sign the images. By coping fom a topDirDest/dirN, also test that non-/restricted paths + // use the dir:"" default of insecureAcceptAnything. + // (For signing, we must push to atomic: to get a Docker identity to use in the signature.) + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "--sign-by", "personal@example.com", topDirDest+"/dir1", "atomic:myns/personal:dirstaging") + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "--sign-by", "official@example.com", topDirDest+"/dir2", "atomic:myns/official:dirstaging") + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/personal:dirstaging", topDirDest+"/restricted/personal") + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/official:dirstaging", topDirDest+"/restricted/official") + + // type: signedBy, with a signedIdentity override (necessary because dir: identities can't be signed) + // Verify that correct images are accepted + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", topDirDest+"/restricted/official", topDirDest+"/dest") + // ... and that mis-signed images are rejected. + assertSkopeoFails(c, ".*Source image rejected: Invalid GPG signature.*", + "--policy", policy, "copy", topDirDest+"/restricted/personal", topDirDest+"/dest") + + // Verify that the signed identity is verified. + assertSkopeoSucceeds(c, "", "--policy", policy, "copy", "--sign-by", "official@example.com", topDirDest+"/dir1", "atomic:myns/personal:dirstaging2") + assertSkopeoSucceeds(c, "", "copy", "atomic:myns/personal:dirstaging2", topDirDest+"/restricted/badidentity") + assertSkopeoFails(c, ".*Source image rejected: .*Signature for identity localhost:8443/myns/personal:dirstaging2 is not accepted.*", + "--policy", policy, "copy", topDirDest+"/restricted/badidentity", topDirDest+"/dest") +} diff --git a/integration/fixtures/policy.json b/integration/fixtures/policy.json new file mode 100644 index 00000000..3326d07b --- /dev/null +++ b/integration/fixtures/policy.json @@ -0,0 +1,84 @@ +{ + "default": [ + { + "type": "reject" + } + ], + "transports": { + "docker": { + "docker.io/openshift": [ + { + "type": "insecureAcceptAnything" + } + ] + }, + "dir": { + "/@dirpath@": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/official-pubkey.gpg", + "signedIdentity": { + "type": "exactRepository", + "dockerRepository": "localhost:8443/myns/official" + } + } + ], + "": [ + { + "type": "insecureAcceptAnything" + } + ] + }, + "atomic": { + "localhost:8443/myns/personal": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/personal-pubkey.gpg" + } + ], + "localhost:8443/myns/official": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/official-pubkey.gpg" + } + ], + "localhost:8443/myns/naming:test1": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/official-pubkey.gpg" + } + ], + "localhost:8443/myns/naming:naming": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/official-pubkey.gpg", + "signedIdentity": { + "type": "exactRepository", + "dockerRepository": "localhost:8443/myns/official" + } + } + ], + "localhost:8443/myns/cosigned:cosigned": [ + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/official-pubkey.gpg", + "signedIdentity": { + "type": "exactRepository", + "dockerRepository": "localhost:8443/myns/official" + } + }, + { + "type": "signedBy", + "keyType": "GPGKeys", + "keyPath": "@keydir@/personal-pubkey.gpg" + } + ] + } + } +} \ No newline at end of file diff --git a/integration/openshift.go b/integration/openshift.go index e55cfa4d..cf9880e6 100644 --- a/integration/openshift.go +++ b/integration/openshift.go @@ -37,6 +37,7 @@ func startOpenshiftCluster(c *check.C) *openshiftCluster { cluster.startRegistry() cluster.ocLoginToProject() cluster.dockerLogin() + cluster.relaxImageSignerPermissions() return cluster } @@ -164,6 +165,20 @@ func (c *openshiftCluster) dockerLogin() { c.c.Assert(err, check.IsNil) } +// relaxImageSignerPermissions opens up the system:image-signer permissions so that +// anyone can work with signatures +// FIXME: This also allows anyone to DoS anyone else; this design is really not all +// that workable, but it is the best we can do for now. +func (c *openshiftCluster) relaxImageSignerPermissions() { + cmd := exec.Command("oadm", "policy", "add-cluster-role-to-group", "system:image-signer", "system:authenticated") + cmd.Dir = c.workingDir + cmd.Env = os.Environ() + cmd.Env = modifyEnviron(cmd.Env, "KUBECONFIG", "openshift.local.config/master/admin.kubeconfig") + out, err := cmd.CombinedOutput() + c.c.Assert(err, check.IsNil, check.Commentf("%s", string(out))) + c.c.Assert(string(out), check.Equals, "") +} + // tearDown stops the cluster services and deletes (only some!) of the state. func (c *openshiftCluster) tearDown() { if c.registry != nil && c.registry.Process != nil {