From fab6bb67988f482e6c167e9deeea387fc7564da7 Mon Sep 17 00:00:00 2001 From: Riyaz Faizullabhoy Date: Thu, 24 Aug 2017 15:45:20 -0700 Subject: [PATCH] trust sign: add docker trust sign command Signed-off-by: Riyaz Faizullabhoy --- cli/command/trust/sign.go | 207 +++++++++++++++++++++++ cli/command/trust/sign_test.go | 291 +++++++++++++++++++++++++++++++++ 2 files changed, 498 insertions(+) create mode 100644 cli/command/trust/sign.go create mode 100644 cli/command/trust/sign_test.go diff --git a/cli/command/trust/sign.go b/cli/command/trust/sign.go new file mode 100644 index 0000000000..277e03864a --- /dev/null +++ b/cli/command/trust/sign.go @@ -0,0 +1,207 @@ +package trust + +import ( + "fmt" + "path" + "sort" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/image" + "github.com/docker/cli/cli/trust" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" + "github.com/spf13/cobra" +) + +func newSignCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "sign [OPTIONS] IMAGE:TAG", + Short: "Sign an image", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return signImage(dockerCli, args[0]) + }, + } + return cmd +} + +func signImage(cli command.Cli, imageName string) error { + ctx, ref, repoInfo, authConfig, err := getImageReferencesAndAuth(cli, imageName) + if err != nil { + return err + } + + notaryRepo, err := trust.GetNotaryRepository(cli, repoInfo, *authConfig, "push", "pull") + if err != nil { + return trust.NotaryError(ref.Name(), err) + } + if err = clearChangeList(notaryRepo); err != nil { + return err + } + defer clearChangeList(notaryRepo) + tag, err := getTag(ref) + if err != nil { + return err + } + if tag == "" { + return fmt.Errorf("No tag specified for %s", imageName) + } + + // get the latest repository metadata so we can figure out which roles to sign + if err = notaryRepo.Update(false); err != nil { + switch err.(type) { + case client.ErrRepoNotInitialized, client.ErrRepositoryNotExist: + // before initializing a new repo, check that the image exists locally: + if err := checkLocalImageExistence(ctx, cli, imageName); err != nil { + return err + } + + userRole := data.RoleName(path.Join(data.CanonicalTargetsRole.String(), authConfig.Username)) + if err := initNotaryRepoWithSigners(notaryRepo, userRole); err != nil { + return trust.NotaryError(ref.Name(), err) + } + + fmt.Fprintf(cli.Out(), "Created signer: %s\n", authConfig.Username) + fmt.Fprintf(cli.Out(), "Finished initializing %q\n", notaryRepo.GetGUN().String()) + default: + return trust.NotaryError(repoInfo.Name.Name(), err) + } + } + requestPrivilege := command.RegistryAuthenticationPrivilegedFunc(cli, repoInfo.Index, "push") + target, err := createTarget(notaryRepo, tag) + if err != nil { + switch err := err.(type) { + case client.ErrNoSuchTarget, client.ErrRepositoryNotExist: + // Fail fast if the image doesn't exist locally + if err := checkLocalImageExistence(ctx, cli, imageName); err != nil { + return err + } + return image.TrustedPush(ctx, cli, repoInfo, ref, *authConfig, requestPrivilege) + default: + return err + } + } + + fmt.Fprintf(cli.Out(), "Signing and pushing trust metadata for %s\n", imageName) + existingSigInfo, err := getExistingSignatureInfoForReleasedTag(notaryRepo, tag) + if err != nil { + return err + } + err = image.AddTargetToAllSignableRoles(notaryRepo, &target) + if err == nil { + prettyPrintExistingSignatureInfo(cli, existingSigInfo) + err = notaryRepo.Publish() + } + if err != nil { + return fmt.Errorf("failed to sign %q:%s - %s", repoInfo.Name.Name(), tag, err.Error()) + } + fmt.Fprintf(cli.Out(), "Successfully signed %q:%s\n", repoInfo.Name.Name(), tag) + return nil +} + +func createTarget(notaryRepo *client.NotaryRepository, tag string) (client.Target, error) { + target := &client.Target{} + var err error + if tag == "" { + return *target, fmt.Errorf("No tag specified") + } + target.Name = tag + target.Hashes, target.Length, err = getSignedManifestHashAndSize(notaryRepo, tag) + return *target, err +} + +func getSignedManifestHashAndSize(notaryRepo *client.NotaryRepository, tag string) (data.Hashes, int64, error) { + targets, err := notaryRepo.GetAllTargetMetadataByName(tag) + if err != nil { + return nil, 0, err + } + return getReleasedTargetHashAndSize(targets, tag) +} + +func getReleasedTargetHashAndSize(targets []client.TargetSignedStruct, tag string) (data.Hashes, int64, error) { + for _, tgt := range targets { + if isReleasedTarget(tgt.Role.Name) { + return tgt.Target.Hashes, tgt.Target.Length, nil + } + } + return nil, 0, client.ErrNoSuchTarget(tag) +} + +func getExistingSignatureInfoForReleasedTag(notaryRepo *client.NotaryRepository, tag string) (trustTagRow, error) { + targets, err := notaryRepo.GetAllTargetMetadataByName(tag) + if err != nil { + return trustTagRow{}, err + } + releasedTargetInfoList := matchReleasedSignatures(targets) + if len(releasedTargetInfoList) == 0 { + return trustTagRow{}, nil + } + return releasedTargetInfoList[0], nil +} + +func prettyPrintExistingSignatureInfo(cli command.Cli, existingSigInfo trustTagRow) { + sort.Strings(existingSigInfo.Signers) + joinedSigners := strings.Join(existingSigInfo.Signers, ", ") + fmt.Fprintf(cli.Out(), "Existing signatures for tag %s digest %s from:\n%s\n", existingSigInfo.TagName, existingSigInfo.HashHex, joinedSigners) +} + +func initNotaryRepoWithSigners(notaryRepo *client.NotaryRepository, newSigner data.RoleName) error { + rootKey, err := getOrGenerateNotaryKey(notaryRepo, data.CanonicalRootRole) + if err != nil { + return err + } + rootKeyID := rootKey.ID() + + // Initialize the notary repository with a remotely managed snapshot key + if err := notaryRepo.Initialize([]string{rootKeyID}, data.CanonicalSnapshotRole); err != nil { + return err + } + + signerKey, err := getOrGenerateNotaryKey(notaryRepo, newSigner) + if err != nil { + return err + } + addStagedSigner(notaryRepo, newSigner, []data.PublicKey{signerKey}) + + return notaryRepo.Publish() +} + +// generates an ECDSA key without a GUN for the specified role +func getOrGenerateNotaryKey(notaryRepo *client.NotaryRepository, role data.RoleName) (data.PublicKey, error) { + // use the signer name in the PEM headers if this is a delegation key + if data.IsDelegation(role) { + role = data.RoleName(notaryRoleToSigner(role)) + } + keys := notaryRepo.CryptoService.ListKeys(role) + var err error + var key data.PublicKey + // always select the first key by ID + if len(keys) > 0 { + sort.Strings(keys) + keyID := keys[0] + privKey, _, err := notaryRepo.CryptoService.GetPrivateKey(keyID) + if err != nil { + return nil, err + } + key = data.PublicKeyFromPrivate(privKey) + } else { + key, err = notaryRepo.CryptoService.Create(role, "", data.ECDSAKey) + if err != nil { + return nil, err + } + } + return key, nil +} + +// stages changes to add a signer with the specified name and key(s). Adds to targets/ and targets/releases +func addStagedSigner(notaryRepo *client.NotaryRepository, newSigner data.RoleName, signerKeys []data.PublicKey) { + // create targets/ + notaryRepo.AddDelegationRoleAndKeys(newSigner, signerKeys) + notaryRepo.AddDelegationPaths(newSigner, []string{""}) + + // create targets/releases + notaryRepo.AddDelegationRoleAndKeys(trust.ReleasesRole, signerKeys) + notaryRepo.AddDelegationPaths(trust.ReleasesRole, []string{""}) +} diff --git a/cli/command/trust/sign_test.go b/cli/command/trust/sign_test.go new file mode 100644 index 0000000000..6849e1b1d9 --- /dev/null +++ b/cli/command/trust/sign_test.go @@ -0,0 +1,291 @@ +package trust + +import ( + "encoding/json" + "io/ioutil" + "os" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/trust" + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/testutil" + "github.com/docker/notary" + "github.com/docker/notary/client" + "github.com/docker/notary/client/changelist" + "github.com/docker/notary/passphrase" + "github.com/docker/notary/trustpinning" + "github.com/docker/notary/tuf/data" + "github.com/stretchr/testify/assert" +) + +const passwd = "password" + +func TestTrustSignErrors(t *testing.T) { + testCases := []struct { + name string + args []string + expectedError string + }{ + { + name: "not-enough-args", + expectedError: "requires exactly 1 argument", + }, + { + name: "too-many-args", + args: []string{"image", "tag"}, + expectedError: "requires exactly 1 argument", + }, + { + name: "sha-reference", + args: []string{"870d292919d01a0af7e7f056271dc78792c05f55f49b9b9012b6d89725bd9abd"}, + expectedError: "invalid repository name", + }, + { + name: "nonexistent-reg", + args: []string{"nonexistent-reg-name.io/image:tag"}, + expectedError: "no such host", + }, + { + name: "invalid-img-reference", + args: []string{"ALPINE:latest"}, + expectedError: "invalid reference format", + }, + { + name: "no-shell-for-passwd", + args: []string{"riyaz/unsigned-img:latest"}, + expectedError: "error during connect: Get /images/riyaz/unsigned-img:latest/json", + }, + { + name: "no-tag", + args: []string{"riyaz/unsigned-img"}, + expectedError: "No tag specified for riyaz/unsigned-img", + }, + { + name: "digest-reference", + args: []string{"ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"}, + expectedError: "cannot use a digest reference for IMAGE:TAG", + }, + { + name: "no-keys", + args: []string{"ubuntu:latest"}, + expectedError: "failed to sign \"docker.io/library/ubuntu\":latest - you are not authorized to perform this operation: server returned 401.", + }, + } + // change to a tmpdir + tmpDir, err := ioutil.TempDir("", "docker-sign-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + for _, tc := range testCases { + cmd := newSignCommand( + test.NewFakeCli(&fakeClient{})) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestGetOrGenerateNotaryKey(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "notary-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + notaryRepo, err := client.NewFileCachedNotaryRepository(tmpDir, "gun", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + + // repo is empty, try making a root key + rootKeyA, err := getOrGenerateNotaryKey(notaryRepo, data.CanonicalRootRole) + assert.NoError(t, err) + assert.NotNil(t, rootKeyA) + + // we should only have one newly generated key + allKeys := notaryRepo.CryptoService.ListAllKeys() + assert.Len(t, allKeys, 1) + assert.NotNil(t, notaryRepo.CryptoService.GetKey(rootKeyA.ID())) + + // this time we should get back the same key if we ask for another root key + rootKeyB, err := getOrGenerateNotaryKey(notaryRepo, data.CanonicalRootRole) + assert.NoError(t, err) + assert.NotNil(t, rootKeyB) + + // we should only have one newly generated key + allKeys = notaryRepo.CryptoService.ListAllKeys() + assert.Len(t, allKeys, 1) + assert.NotNil(t, notaryRepo.CryptoService.GetKey(rootKeyB.ID())) + + // The key we retrieved should be identical to the one we generated + assert.Equal(t, rootKeyA, rootKeyB) + + // Now also try with a delegation key + releasesKey, err := getOrGenerateNotaryKey(notaryRepo, data.RoleName(trust.ReleasesRole)) + assert.NoError(t, err) + assert.NotNil(t, releasesKey) + + // we should now have two keys + allKeys = notaryRepo.CryptoService.ListAllKeys() + assert.Len(t, allKeys, 2) + assert.NotNil(t, notaryRepo.CryptoService.GetKey(releasesKey.ID())) + // The key we retrieved should be identical to the one we generated + assert.NotEqual(t, releasesKey, rootKeyA) + assert.NotEqual(t, releasesKey, rootKeyB) +} + +func TestAddStageSigners(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "notary-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + notaryRepo, err := client.NewFileCachedNotaryRepository(tmpDir, "gun", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + + // stage targets/user + userRole := data.RoleName("targets/user") + userKey := data.NewPublicKey("algoA", []byte("a")) + addStagedSigner(notaryRepo, userRole, []data.PublicKey{userKey}) + // check the changelist for four total changes: two on targets/releases and two on targets/user + cl, err := notaryRepo.GetChangelist() + assert.NoError(t, err) + changeList := cl.List() + assert.Len(t, changeList, 4) + // ordering is determinstic: + + // first change is for targets/user key creation + newSignerKeyChange := changeList[0] + expectedJSON, err := json.Marshal(&changelist.TUFDelegation{ + NewThreshold: notary.MinThreshold, + AddKeys: data.KeyList([]data.PublicKey{userKey}), + }) + expectedChange := changelist.NewTUFChange( + changelist.ActionCreate, + userRole, + changelist.TypeTargetsDelegation, + "", // no path for delegations + expectedJSON, + ) + assert.Equal(t, expectedChange, newSignerKeyChange) + + // second change is for targets/user getting all paths + newSignerPathsChange := changeList[1] + expectedJSON, err = json.Marshal(&changelist.TUFDelegation{ + AddPaths: []string{""}, + }) + expectedChange = changelist.NewTUFChange( + changelist.ActionCreate, + userRole, + changelist.TypeTargetsDelegation, + "", // no path for delegations + expectedJSON, + ) + assert.Equal(t, expectedChange, newSignerPathsChange) + + releasesRole := data.RoleName("targets/releases") + + // third change is for targets/releases key creation + releasesKeyChange := changeList[2] + expectedJSON, err = json.Marshal(&changelist.TUFDelegation{ + NewThreshold: notary.MinThreshold, + AddKeys: data.KeyList([]data.PublicKey{userKey}), + }) + expectedChange = changelist.NewTUFChange( + changelist.ActionCreate, + releasesRole, + changelist.TypeTargetsDelegation, + "", // no path for delegations + expectedJSON, + ) + assert.Equal(t, expectedChange, releasesKeyChange) + + // fourth change is for targets/releases getting all paths + releasesPathsChange := changeList[3] + expectedJSON, err = json.Marshal(&changelist.TUFDelegation{ + AddPaths: []string{""}, + }) + expectedChange = changelist.NewTUFChange( + changelist.ActionCreate, + releasesRole, + changelist.TypeTargetsDelegation, + "", // no path for delegations + expectedJSON, + ) + assert.Equal(t, expectedChange, releasesPathsChange) +} + +func TestGetSignedManifestHashAndSize(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "notary-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + notaryRepo, err := client.NewFileCachedNotaryRepository(tmpDir, "gun", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + target := &client.Target{} + target.Hashes, target.Length, err = getSignedManifestHashAndSize(notaryRepo, "test") + assert.EqualError(t, err, "client is offline") +} + +func TestGetReleasedTargetHashAndSize(t *testing.T) { + oneReleasedTgt := []client.TargetSignedStruct{} + // make and append 3 non-released signatures on the "unreleased" target + unreleasedTgt := client.Target{Name: "unreleased", Hashes: data.Hashes{notary.SHA256: []byte("hash")}} + for _, unreleasedRole := range []string{"targets/a", "targets/b", "targets/c"} { + oneReleasedTgt = append(oneReleasedTgt, client.TargetSignedStruct{Role: mockDelegationRoleWithName(unreleasedRole), Target: unreleasedTgt}) + } + _, _, err := getReleasedTargetHashAndSize(oneReleasedTgt, "unreleased") + assert.EqualError(t, err, "No valid trust data for unreleased") + releasedTgt := client.Target{Name: "released", Hashes: data.Hashes{notary.SHA256: []byte("released-hash")}} + oneReleasedTgt = append(oneReleasedTgt, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/releases"), Target: releasedTgt}) + hash, _, _ := getReleasedTargetHashAndSize(oneReleasedTgt, "unreleased") + assert.Equal(t, data.Hashes{notary.SHA256: []byte("released-hash")}, hash) + +} + +func TestCreateTarget(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "notary-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + notaryRepo, err := client.NewFileCachedNotaryRepository(tmpDir, "gun", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + _, err = createTarget(notaryRepo, "") + assert.EqualError(t, err, "No tag specified") + _, err = createTarget(notaryRepo, "1") + assert.EqualError(t, err, "client is offline") +} + +func TestGetExistingSignatureInfoForReleasedTag(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "notary-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + notaryRepo, err := client.NewFileCachedNotaryRepository(tmpDir, "gun", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + _, err = getExistingSignatureInfoForReleasedTag(notaryRepo, "test") + assert.EqualError(t, err, "client is offline") +} + +func TestPrettyPrintExistingSignatureInfo(t *testing.T) { + fakeCli := test.NewFakeCli(&fakeClient{}) + + signers := []string{"Bob", "Alice", "Carol"} + existingSig := trustTagRow{trustTagKey{"tagName", "abc123"}, signers} + prettyPrintExistingSignatureInfo(fakeCli, existingSig) + + assert.Contains(t, fakeCli.OutBuffer().String(), "Existing signatures for tag tagName digest abc123 from:\nAlice, Bob, Carol") +} + +func TestChangeList(t *testing.T) { + + tmpDir, err := ioutil.TempDir("", "docker-sign-test-") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + config.SetDir(tmpDir) + cmd := newSignCommand( + test.NewFakeCli(&fakeClient{})) + cmd.SetArgs([]string{"ubuntu:latest"}) + cmd.SetOutput(ioutil.Discard) + err = cmd.Execute() + notaryRepo, err := client.NewFileCachedNotaryRepository(tmpDir, "docker.io/library/ubuntu", "https://localhost", nil, passphrase.ConstantRetriever(passwd), trustpinning.TrustPinConfig{}) + assert.NoError(t, err) + cl, err := notaryRepo.GetChangelist() + assert.Equal(t, len(cl.List()), 0) +}