diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index c6fc8d6ace..6645770095 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -17,6 +17,7 @@ import ( "github.com/docker/cli/cli/command/stack" "github.com/docker/cli/cli/command/swarm" "github.com/docker/cli/cli/command/system" + "github.com/docker/cli/cli/command/trust" "github.com/docker/cli/cli/command/volume" "github.com/spf13/cobra" ) @@ -69,6 +70,9 @@ func AddCommands(cmd *cobra.Command, dockerCli *command.DockerCli) { // swarm swarm.NewSwarmCommand(dockerCli), + // trust + trust.NewTrustCommand(dockerCli), + // volume volume.NewVolumeCommand(dockerCli), diff --git a/cli/command/formatter/trust.go b/cli/command/formatter/trust.go new file mode 100644 index 0000000000..c16df2e100 --- /dev/null +++ b/cli/command/formatter/trust.go @@ -0,0 +1,150 @@ +package formatter + +import ( + "sort" + "strings" + + "github.com/docker/docker/pkg/stringid" +) + +const ( + defaultTrustTagTableFormat = "table {{.SignedTag}}\t{{.Digest}}\t{{.Signers}}" + signedTagNameHeader = "SIGNED TAG" + trustedDigestHeader = "DIGEST" + signersHeader = "SIGNERS" + defaultSignerInfoTableFormat = "table {{.Signer}}\t{{.Keys}}" + signerNameHeader = "SIGNER" + keysHeader = "KEYS" +) + +// SignedTagInfo represents all formatted information needed to describe a signed tag: +// Name: name of the signed tag +// Digest: hex encoded digest of the contents +// Signers: list of entities who signed the tag +type SignedTagInfo struct { + Name string + Digest string + Signers []string +} + +// SignerInfo represents all formatted information needed to describe a signer: +// Name: name of the signer role +// Keys: the keys associated with the signer +type SignerInfo struct { + Name string + Keys []string +} + +// NewTrustTagFormat returns a Format for rendering using a trusted tag Context +func NewTrustTagFormat() Format { + return defaultTrustTagTableFormat +} + +// NewSignerInfoFormat returns a Format for rendering a signer role info Context +func NewSignerInfoFormat() Format { + return defaultSignerInfoTableFormat +} + +// TrustTagWrite writes the context +func TrustTagWrite(ctx Context, signedTagInfoList []SignedTagInfo) error { + render := func(format func(subContext subContext) error) error { + for _, signedTag := range signedTagInfoList { + if err := format(&trustTagContext{s: signedTag}); err != nil { + return err + } + } + return nil + } + trustTagCtx := trustTagContext{} + trustTagCtx.header = trustTagHeaderContext{ + "SignedTag": signedTagNameHeader, + "Digest": trustedDigestHeader, + "Signers": signersHeader, + } + return ctx.Write(&trustTagCtx, render) +} + +type trustTagHeaderContext map[string]string + +type trustTagContext struct { + HeaderContext + s SignedTagInfo +} + +// SignedTag returns the name of the signed tag +func (c *trustTagContext) SignedTag() string { + return c.s.Name +} + +// Digest returns the hex encoded digest associated with this signed tag +func (c *trustTagContext) Digest() string { + return c.s.Digest +} + +// Signers returns the sorted list of entities who signed this tag +func (c *trustTagContext) Signers() string { + sort.Strings(c.s.Signers) + return strings.Join(c.s.Signers, ", ") +} + +// SignerInfoWrite writes the context +func SignerInfoWrite(ctx Context, signerInfoList []SignerInfo) error { + render := func(format func(subContext subContext) error) error { + for _, signerInfo := range signerInfoList { + if err := format(&signerInfoContext{ + trunc: ctx.Trunc, + s: signerInfo, + }); err != nil { + return err + } + } + return nil + } + signerInfoCtx := signerInfoContext{} + signerInfoCtx.header = signerInfoHeaderContext{ + "Signer": signerNameHeader, + "Keys": keysHeader, + } + return ctx.Write(&signerInfoCtx, render) +} + +type signerInfoHeaderContext map[string]string + +type signerInfoContext struct { + HeaderContext + trunc bool + s SignerInfo +} + +// Keys returns the sorted list of keys associated with the signer +func (c *signerInfoContext) Keys() string { + sort.Strings(c.s.Keys) + truncatedKeys := []string{} + if c.trunc { + for _, keyID := range c.s.Keys { + truncatedKeys = append(truncatedKeys, stringid.TruncateID(keyID)) + } + return strings.Join(truncatedKeys, ", ") + } + return strings.Join(c.s.Keys, ", ") +} + +// Signer returns the name of the signer +func (c *signerInfoContext) Signer() string { + return c.s.Name +} + +// SignerInfoList helps sort []SignerInfo by signer names +type SignerInfoList []SignerInfo + +func (signerInfoComp SignerInfoList) Len() int { + return len(signerInfoComp) +} + +func (signerInfoComp SignerInfoList) Less(i, j int) bool { + return signerInfoComp[i].Name < signerInfoComp[j].Name +} + +func (signerInfoComp SignerInfoList) Swap(i, j int) { + signerInfoComp[i], signerInfoComp[j] = signerInfoComp[j], signerInfoComp[i] +} diff --git a/cli/command/formatter/trust_test.go b/cli/command/formatter/trust_test.go new file mode 100644 index 0000000000..80175e14a3 --- /dev/null +++ b/cli/command/formatter/trust_test.go @@ -0,0 +1,238 @@ +package formatter + +import ( + "bytes" + "testing" + + "github.com/docker/docker/pkg/stringid" + "github.com/stretchr/testify/assert" +) + +func TestTrustTag(t *testing.T) { + digest := stringid.GenerateRandomID() + trustedTag := "tag" + + var ctx trustTagContext + + cases := []struct { + trustTagCtx trustTagContext + expValue string + call func() string + }{ + { + trustTagContext{ + s: SignedTagInfo{Name: trustedTag, + Digest: digest, + Signers: nil, + }, + }, + digest, + ctx.Digest, + }, + { + trustTagContext{ + s: SignedTagInfo{Name: trustedTag, + Digest: digest, + Signers: nil, + }, + }, + trustedTag, + ctx.SignedTag, + }, + // Empty signers makes a row with empty string + { + trustTagContext{ + s: SignedTagInfo{Name: trustedTag, + Digest: digest, + Signers: nil, + }, + }, + "", + ctx.Signers, + }, + { + trustTagContext{ + s: SignedTagInfo{Name: trustedTag, + Digest: digest, + Signers: []string{"ashwini", "kyle", "riyaz"}, + }, + }, + "ashwini, kyle, riyaz", + ctx.Signers, + }, + // alphabetic signing on Signers + { + trustTagContext{ + s: SignedTagInfo{Name: trustedTag, + Digest: digest, + Signers: []string{"riyaz", "kyle", "ashwini"}, + }, + }, + "ashwini, kyle, riyaz", + ctx.Signers, + }, + } + + for _, c := range cases { + ctx = c.trustTagCtx + v := c.call() + if v != c.expValue { + t.Fatalf("Expected %s, was %s\n", c.expValue, v) + } + } +} + +func TestTrustTagContextWrite(t *testing.T) { + + cases := []struct { + context Context + expected string + }{ + // Errors + { + Context{ + Format: "{{InvalidFunction}}", + }, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + Context{ + Format: "{{nil}}", + }, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table Format + { + Context{ + Format: NewTrustTagFormat(), + }, + `SIGNED TAG DIGEST SIGNERS +tag1 deadbeef alice +tag2 aaaaaaaa alice, bob +tag3 bbbbbbbb +`, + }, + } + + for _, testcase := range cases { + signedTags := []SignedTagInfo{ + {Name: "tag1", Digest: "deadbeef", Signers: []string{"alice"}}, + {Name: "tag2", Digest: "aaaaaaaa", Signers: []string{"alice", "bob"}}, + {Name: "tag3", Digest: "bbbbbbbb", Signers: []string{}}, + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := TrustTagWrite(testcase.context, signedTags) + if err != nil { + assert.EqualError(t, err, testcase.expected) + } else { + assert.Equal(t, testcase.expected, out.String()) + } + } +} + +// With no trust data, the TrustTagWrite will print an empty table: +// it's up to the caller to decide whether or not to print this versus an error +func TestTrustTagContextEmptyWrite(t *testing.T) { + + emptyCase := struct { + context Context + expected string + }{ + Context{ + Format: NewTrustTagFormat(), + }, + `SIGNED TAG DIGEST SIGNERS +`, + } + + emptySignedTags := []SignedTagInfo{} + out := bytes.NewBufferString("") + emptyCase.context.Output = out + err := TrustTagWrite(emptyCase.context, emptySignedTags) + assert.NoError(t, err) + assert.Equal(t, emptyCase.expected, out.String()) +} + +func TestSignerInfoContextEmptyWrite(t *testing.T) { + emptyCase := struct { + context Context + expected string + }{ + Context{ + Format: NewSignerInfoFormat(), + }, + `SIGNER KEYS +`, + } + emptySignerInfo := []SignerInfo{} + out := bytes.NewBufferString("") + emptyCase.context.Output = out + err := SignerInfoWrite(emptyCase.context, emptySignerInfo) + assert.NoError(t, err) + assert.Equal(t, emptyCase.expected, out.String()) +} + +func TestSignerInfoContextWrite(t *testing.T) { + cases := []struct { + context Context + expected string + }{ + // Errors + { + Context{ + Format: "{{InvalidFunction}}", + }, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + Context{ + Format: "{{nil}}", + }, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table Format + { + Context{ + Format: NewSignerInfoFormat(), + Trunc: true, + }, + `SIGNER KEYS +alice key11, key12 +bob key21 +eve foobarbazqux, key31, key32 +`, + }, + // No truncation + { + Context{ + Format: NewSignerInfoFormat(), + }, + `SIGNER KEYS +alice key11, key12 +bob key21 +eve foobarbazquxquux, key31, key32 +`, + }, + } + + for _, testcase := range cases { + signerInfo := SignerInfoList{ + {Name: "alice", Keys: []string{"key11", "key12"}}, + {Name: "bob", Keys: []string{"key21"}}, + {Name: "eve", Keys: []string{"key31", "key32", "foobarbazquxquux"}}, + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := SignerInfoWrite(testcase.context, signerInfo) + if err != nil { + assert.EqualError(t, err, testcase.expected) + } else { + assert.Equal(t, testcase.expected, out.String()) + } + } +} diff --git a/cli/command/trust/cmd.go b/cli/command/trust/cmd.go new file mode 100644 index 0000000000..8b079dcd0e --- /dev/null +++ b/cli/command/trust/cmd.go @@ -0,0 +1,23 @@ +package trust + +import ( + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// NewTrustCommand returns a cobra command for `trust` subcommands +func NewTrustCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "trust", + Short: "Sign images to establish trust", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + } + cmd.AddCommand( + newInspectCommand(dockerCli), + newRevokeCommand(dockerCli), + newSignCommand(dockerCli), + ) + return cmd +} diff --git a/cli/command/trust/helpers.go b/cli/command/trust/helpers.go new file mode 100644 index 0000000000..590000b2ae --- /dev/null +++ b/cli/command/trust/helpers.go @@ -0,0 +1,90 @@ +package trust + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/trust" + "github.com/docker/distribution/reference" + "github.com/docker/docker/api/types" + "github.com/docker/docker/registry" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" +) + +const releasedRoleName = "Repo Admin" + +func checkLocalImageExistence(ctx context.Context, cli command.Cli, imageName string) error { + _, _, err := cli.Client().ImageInspectWithRaw(ctx, imageName) + return err +} + +func getImageReferencesAndAuth(cli command.Cli, imgName string) (context.Context, reference.Named, *registry.RepositoryInfo, *types.AuthConfig, error) { + ref, err := reference.ParseNormalizedNamed(imgName) + if err != nil { + return nil, nil, nil, nil, err + } + + // Resolve the Repository name from fqn to RepositoryInfo + repoInfo, err := registry.ParseRepositoryInfo(ref) + if err != nil { + return nil, nil, nil, nil, err + } + + ctx := context.Background() + authConfig := command.ResolveAuthConfig(ctx, cli, repoInfo.Index) + return ctx, ref, repoInfo, &authConfig, err +} + +func getTag(ref reference.Named) (string, error) { + var tag string + switch x := ref.(type) { + case reference.Canonical: + return "", fmt.Errorf("cannot use a digest reference for IMAGE:TAG") + case reference.NamedTagged: + tag = x.Tag() + default: + tag = "" + } + return tag, nil +} + +// check if a role name is "released": either targets/releases or targets TUF roles +func isReleasedTarget(role data.RoleName) bool { + return role == data.CanonicalTargetsRole || role == trust.ReleasesRole +} + +// convert TUF role name to a human-understandable signer name +func notaryRoleToSigner(tufRole data.RoleName) string { + // don't show a signer for "targets" or "targets/releases" + if isReleasedTarget(data.RoleName(tufRole.String())) { + return releasedRoleName + } + return strings.TrimPrefix(tufRole.String(), "targets/") +} + +func askConfirm(input io.Reader) bool { + var res string + if _, err := fmt.Fscanln(input, &res); err != nil { + return false + } + if strings.EqualFold(res, "y") || strings.EqualFold(res, "yes") { + return true + } + return false +} + +func clearChangeList(notaryRepo *client.NotaryRepository) error { + + cl, err := notaryRepo.GetChangelist() + if err != nil { + return err + } + if err = cl.Clear(""); err != nil { + return err + } + return nil +} diff --git a/cli/command/trust/helpers_test.go b/cli/command/trust/helpers_test.go new file mode 100644 index 0000000000..c4fc831b66 --- /dev/null +++ b/cli/command/trust/helpers_test.go @@ -0,0 +1,25 @@ +package trust + +import ( + "testing" + + "github.com/docker/distribution/reference" + "github.com/stretchr/testify/assert" +) + +func TestGetTag(t *testing.T) { + ref, err := reference.ParseNormalizedNamed("ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2") + assert.NoError(t, err) + tag, err := getTag(ref) + assert.EqualError(t, err, "cannot use a digest reference for IMAGE:TAG") + + ref, err = reference.ParseNormalizedNamed("alpine:latest") + assert.NoError(t, err) + tag, err = getTag(ref) + assert.Equal(t, tag, "latest") + + ref, err = reference.ParseNormalizedNamed("alpine") + assert.NoError(t, err) + tag, err = getTag(ref) + assert.Equal(t, tag, "") +} diff --git a/cli/command/trust/inspect.go b/cli/command/trust/inspect.go new file mode 100644 index 0000000000..d0ecd0e9fe --- /dev/null +++ b/cli/command/trust/inspect.go @@ -0,0 +1,230 @@ +package trust + +import ( + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/cli/cli/trust" + "github.com/docker/notary" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" + "github.com/spf13/cobra" +) + +// trustTagKey represents a unique signed tag and hex-encoded hash pair +type trustTagKey struct { + TagName string + HashHex string +} + +// trustTagRow encodes all human-consumable information for a signed tag, including signers +type trustTagRow struct { + trustTagKey + Signers []string +} + +type trustTagRowList []trustTagRow + +func (tagComparator trustTagRowList) Len() int { + return len(tagComparator) +} + +func (tagComparator trustTagRowList) Less(i, j int) bool { + return tagComparator[i].TagName < tagComparator[j].TagName +} + +func (tagComparator trustTagRowList) Swap(i, j int) { + tagComparator[i], tagComparator[j] = tagComparator[j], tagComparator[i] +} + +func newInspectCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect [OPTIONS] IMAGE[:TAG]", + Short: "Display detailed information about keys and signatures", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return lookupTrustInfo(dockerCli, args[0]) + }, + } + return cmd +} + +func lookupTrustInfo(cli command.Cli, remote string) error { + _, ref, repoInfo, authConfig, err := getImageReferencesAndAuth(cli, remote) + if err != nil { + return err + } + notaryRepo, err := trust.GetNotaryRepository(cli, repoInfo, *authConfig, "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 + } + // Retrieve all released signatures, match them, and pretty print them + allSignedTargets, err := notaryRepo.GetAllTargetMetadataByName(tag) + if err != nil { + logrus.Debug(trust.NotaryError(ref.Name(), err)) + // print an empty table if we don't have signed targets, but have an initialized notary repo + if _, ok := err.(client.ErrNoSuchTarget); !ok { + return fmt.Errorf("No signatures or cannot access %s", remote) + } + } + signatureRows := matchReleasedSignatures(allSignedTargets) + if len(signatureRows) > 0 { + if err := printSignatures(cli, signatureRows); err != nil { + return err + } + } else { + fmt.Fprintf(cli.Out(), "\nNo signatures for %s\n\n", remote) + } + + // get the administrative roles + roleWithSigs, err := notaryRepo.ListRoles() + if err != nil { + return fmt.Errorf("No signers for %s", remote) + } + adminRoleToKeyIDs := getAdministrativeRolesToKeyMap(roleWithSigs) + + // get delegation roles with the canonical key IDs + delegationRoles, err := notaryRepo.GetDelegationRoles() + if err != nil { + logrus.Debugf("no delegation roles found, or error fetching them for %s: %v", remote, err) + } + signerRoleToKeyIDs := getDelegationRoleToKeyMap(delegationRoles) + + // If we do not have additional signers, do not display + if len(signerRoleToKeyIDs) > 0 { + fmt.Fprintf(cli.Out(), "\nList of signers and their KeyIDs:\n\n") + printSignerInfo(cli, signerRoleToKeyIDs) + } + + // This will always have the root and targets information + fmt.Fprintf(cli.Out(), "\nAdministrative keys for %s:\n", strings.Split(remote, ":")[0]) + printSortedAdminKeys(adminRoleToKeyIDs, cli) + + return nil +} + +func printSortedAdminKeys(adminRoleToKeyIDs map[string]string, cli command.Cli) { + keyNames := []string{} + for name := range adminRoleToKeyIDs { + keyNames = append(keyNames, name) + } + + sort.Strings(keyNames) + + for _, keyName := range keyNames { + fmt.Fprintf(cli.Out(), "%s:\t%s\n", keyName, adminRoleToKeyIDs[keyName]) + } +} + +func getAdministrativeRolesToKeyMap(roleWithSigs []client.RoleWithSignatures) map[string]string { + adminRoleToKeyIDs := make(map[string]string) + for _, roleWithSig := range roleWithSigs { + sort.Strings(roleWithSig.KeyIDs) + switch roleWithSig.Name { + case data.CanonicalTargetsRole: + adminRoleToKeyIDs["Repository Key"] = strings.Join(roleWithSig.KeyIDs, ", ") + case data.CanonicalRootRole: + adminRoleToKeyIDs["Root Key"] = strings.Join(roleWithSig.KeyIDs, ", ") + default: + continue + } + } + return adminRoleToKeyIDs +} + +func getDelegationRoleToKeyMap(rawDelegationRoles []data.Role) map[string][]string { + signerRoleToKeyIDs := make(map[string][]string) + for _, delRole := range rawDelegationRoles { + switch delRole.Name { + case trust.ReleasesRole, data.CanonicalRootRole, data.CanonicalSnapshotRole, data.CanonicalTargetsRole, data.CanonicalTimestampRole: + continue + default: + signerRoleToKeyIDs[notaryRoleToSigner(delRole.Name)] = delRole.KeyIDs + } + } + return signerRoleToKeyIDs +} + +// aggregate all signers for a "released" hash+tagname pair. To be "released," the tag must have been +// signed into the "targets" or "targets/releases" role. Output is sorted by tag name +func matchReleasedSignatures(allTargets []client.TargetSignedStruct) trustTagRowList { + signatureRows := trustTagRowList{} + // do a first pass to get filter on tags signed into "targets" or "targets/releases" + releasedTargetRows := map[trustTagKey][]string{} + for _, tgt := range allTargets { + if isReleasedTarget(tgt.Role.Name) { + releasedKey := trustTagKey{tgt.Target.Name, hex.EncodeToString(tgt.Target.Hashes[notary.SHA256])} + releasedTargetRows[releasedKey] = []string{} + } + } + + // now fill out all signers on released keys + for _, tgt := range allTargets { + targetKey := trustTagKey{tgt.Target.Name, hex.EncodeToString(tgt.Target.Hashes[notary.SHA256])} + // only considered released targets + if _, ok := releasedTargetRows[targetKey]; ok && !isReleasedTarget(tgt.Role.Name) { + releasedTargetRows[targetKey] = append(releasedTargetRows[targetKey], notaryRoleToSigner(tgt.Role.Name)) + } + } + + // compile the final output as a sorted slice + for targetKey, signers := range releasedTargetRows { + signatureRows = append(signatureRows, trustTagRow{targetKey, signers}) + } + sort.Sort(signatureRows) + return signatureRows +} + +// pretty print with ordered rows +func printSignatures(dockerCli command.Cli, signatureRows trustTagRowList) error { + trustTagCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewTrustTagFormat(), + } + // convert the formatted type before printing + formattedTags := []formatter.SignedTagInfo{} + for _, sigRow := range signatureRows { + formattedSigners := sigRow.Signers + if len(formattedSigners) == 0 { + formattedSigners = append(formattedSigners, fmt.Sprintf("(%s)", releasedRoleName)) + } + formattedTags = append(formattedTags, formatter.SignedTagInfo{ + Name: sigRow.TagName, + Digest: sigRow.HashHex, + Signers: formattedSigners, + }) + } + return formatter.TrustTagWrite(trustTagCtx, formattedTags) +} + +func printSignerInfo(dockerCli command.Cli, roleToKeyIDs map[string][]string) error { + signerInfoCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewSignerInfoFormat(), + Trunc: true, + } + formattedSignerInfo := formatter.SignerInfoList{} + for name, keyIDs := range roleToKeyIDs { + formattedSignerInfo = append(formattedSignerInfo, formatter.SignerInfo{ + Name: name, + Keys: keyIDs, + }) + } + sort.Sort(formattedSignerInfo) + return formatter.SignerInfoWrite(signerInfoCtx, formattedSignerInfo) +} diff --git a/cli/command/trust/inspect_test.go b/cli/command/trust/inspect_test.go new file mode 100644 index 0000000000..578ab9627d --- /dev/null +++ b/cli/command/trust/inspect_test.go @@ -0,0 +1,380 @@ +package trust + +import ( + "encoding/hex" + "io/ioutil" + "testing" + + "github.com/docker/cli/cli/trust" + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/testutil" + dockerClient "github.com/docker/docker/client" + "github.com/docker/notary" + "github.com/docker/notary/client" + "github.com/docker/notary/tuf/data" + "github.com/stretchr/testify/assert" +) + +type fakeClient struct { + dockerClient.Client +} + +func TestTrustInfoErrors(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{"remote1", "remote2"}, + 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"}, + expectedError: "No signatures or cannot access nonexistent-reg-name.io/image", + }, + { + name: "invalid-img-reference", + args: []string{"ALPINE"}, + expectedError: "invalid reference format", + }, + { + name: "unsigned-img-reference", + args: []string{"riyaz/unsigned-img"}, + expectedError: "No signatures or cannot access riyaz/unsigned-img", + }, + { + name: "nonexistent-img-reference", + args: []string{"riyaz/nonexistent-img"}, + expectedError: "No signatures or cannot access riyaz/nonexistent-img", + }, + } + for _, tc := range testCases { + cmd := newInspectCommand( + test.NewFakeCli(&fakeClient{})) + cmd.SetArgs(tc.args) + cmd.SetOutput(ioutil.Discard) + testutil.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestTrustInfo(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + cmd := newInspectCommand(cli) + cmd.SetArgs([]string{"alpine"}) + assert.NoError(t, cmd.Execute()) + + // Check for the signed tag headers + assert.Contains(t, cli.OutBuffer().String(), "SIGNED TAG") + assert.Contains(t, cli.OutBuffer().String(), "DIGEST") + assert.Contains(t, cli.OutBuffer().String(), "SIGNERS") + // Check for the signer headers + assert.Contains(t, cli.OutBuffer().String(), "Administrative keys for alpine:") + assert.Contains(t, cli.OutBuffer().String(), "(Repo Admin)") + // no delegations on this repo + assert.NotContains(t, cli.OutBuffer().String(), "List of signers and their KeyIDs:") + + cli = test.NewFakeCli(&fakeClient{}) + cmd = newInspectCommand(cli) + cmd.SetArgs([]string{"alpine:3.5"}) + assert.NoError(t, cmd.Execute()) + assert.Contains(t, cli.OutBuffer().String(), "SIGNED TAG") + assert.Contains(t, cli.OutBuffer().String(), "DIGEST") + assert.Contains(t, cli.OutBuffer().String(), "SIGNERS") + // Check for the signer headers + assert.Contains(t, cli.OutBuffer().String(), "Administrative keys for alpine:") + // make sure the tag isn't included + assert.NotContains(t, cli.OutBuffer().String(), "Administrative keys for alpine:3.5") + assert.Contains(t, cli.OutBuffer().String(), "3.5") + assert.Contains(t, cli.OutBuffer().String(), "(Repo Admin)") + // no delegations on this repo + assert.NotContains(t, cli.OutBuffer().String(), "3.6") + assert.NotContains(t, cli.OutBuffer().String(), "List of signers and their KeyIDs:") + + cli = test.NewFakeCli(&fakeClient{}) + cmd = newInspectCommand(cli) + cmd.SetArgs([]string{"dockerorcadev/trust-fixture"}) + assert.NoError(t, cmd.Execute()) + + // Check for the signed tag headers + assert.Contains(t, cli.OutBuffer().String(), "SIGNED TAG") + assert.Contains(t, cli.OutBuffer().String(), "DIGEST") + assert.Contains(t, cli.OutBuffer().String(), "SIGNERS") + // Check for the signer headers + assert.Contains(t, cli.OutBuffer().String(), "List of signers and their KeyIDs:") + assert.Contains(t, cli.OutBuffer().String(), "SIGNER") + assert.Contains(t, cli.OutBuffer().String(), "KEYS") + assert.Contains(t, cli.OutBuffer().String(), "Administrative keys for dockerorcadev/trust-fixture:") + assert.Contains(t, cli.OutBuffer().String(), "Repository Key") + assert.Contains(t, cli.OutBuffer().String(), "Root Key") + // all signers have names + assert.NotContains(t, cli.OutBuffer().String(), "(Repo Admin)") + + cli = test.NewFakeCli(&fakeClient{}) + cmd = newInspectCommand(cli) + cmd.SetArgs([]string{"dockerorcadev/trust-fixture:unsigned"}) + assert.NoError(t, cmd.Execute()) + + // Check that the signatures table does not show up, and instead we get the message + assert.Contains(t, cli.OutBuffer().String(), "No signatures for dockerorcadev/trust-fixture:unsigned") + assert.NotContains(t, cli.OutBuffer().String(), "SIGNED TAG") + assert.NotContains(t, cli.OutBuffer().String(), "DIGEST") + assert.NotContains(t, cli.OutBuffer().String(), "SIGNERS") + // Check for the signer headers + assert.Contains(t, cli.OutBuffer().String(), "List of signers and their KeyIDs:") + assert.Contains(t, cli.OutBuffer().String(), "SIGNER") + assert.Contains(t, cli.OutBuffer().String(), "KEYS") + assert.Contains(t, cli.OutBuffer().String(), "Administrative keys for dockerorcadev/trust-fixture:") + // make sure the tag isn't included + assert.NotContains(t, cli.OutBuffer().String(), "Administrative keys for dockerorcadev/trust-fixture:unsigned") + assert.Contains(t, cli.OutBuffer().String(), "Repository Key") + assert.Contains(t, cli.OutBuffer().String(), "Root Key") + // all signers have names + assert.NotContains(t, cli.OutBuffer().String(), "(Repo Admin)") +} + +func TestTUFToSigner(t *testing.T) { + assert.Equal(t, releasedRoleName, notaryRoleToSigner(data.CanonicalTargetsRole)) + assert.Equal(t, releasedRoleName, notaryRoleToSigner(trust.ReleasesRole)) + assert.Equal(t, "signer", notaryRoleToSigner("targets/signer")) + assert.Equal(t, "docker/signer", notaryRoleToSigner("targets/docker/signer")) + + // It's nonsense for other base roles to have signed off on a target, but this function leaves role names intact + for _, role := range data.BaseRoles { + if role == data.CanonicalTargetsRole { + continue + } + assert.Equal(t, role.String(), notaryRoleToSigner(role)) + } + assert.Equal(t, "notarole", notaryRoleToSigner(data.RoleName("notarole"))) +} + +// check if a role name is "released": either targets/releases or targets TUF roles +func TestIsReleasedTarget(t *testing.T) { + assert.True(t, isReleasedTarget(trust.ReleasesRole)) + for _, role := range data.BaseRoles { + assert.Equal(t, role == data.CanonicalTargetsRole, isReleasedTarget(role)) + } + assert.False(t, isReleasedTarget(data.RoleName("targets/not-releases"))) + assert.False(t, isReleasedTarget(data.RoleName("random"))) + assert.False(t, isReleasedTarget(data.RoleName("targets/releases/subrole"))) +} + +// creates a mock delegation with a given name and no keys +func mockDelegationRoleWithName(name string) data.DelegationRole { + baseRole := data.NewBaseRole( + data.RoleName(name), + notary.MinThreshold, + ) + return data.DelegationRole{baseRole, []string{}} +} + +func TestMatchEmptySignatures(t *testing.T) { + // first try empty targets + emptyTgts := []client.TargetSignedStruct{} + + matchedSigRows := matchReleasedSignatures(emptyTgts) + assert.Empty(t, matchedSigRows) +} + +func TestMatchUnreleasedSignatures(t *testing.T) { + // try an "unreleased" target with 3 signatures, 0 rows will appear + unreleasedTgts := []client.TargetSignedStruct{} + + tgt := client.Target{Name: "unreleased", Hashes: data.Hashes{notary.SHA256: []byte("hash")}} + for _, unreleasedRole := range []string{"targets/a", "targets/b", "targets/c"} { + unreleasedTgts = append(unreleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName(unreleasedRole), Target: tgt}) + } + + matchedSigRows := matchReleasedSignatures(unreleasedTgts) + assert.Empty(t, matchedSigRows) +} + +func TestMatchOneReleasedSingleSignature(t *testing.T) { + // now try only 1 "released" target with no additional sigs, 1 row will appear with 0 signers + oneReleasedTgt := []client.TargetSignedStruct{} + + // make and append the "released" target to our mock input + releasedTgt := client.Target{Name: "released", Hashes: data.Hashes{notary.SHA256: []byte("released-hash")}} + oneReleasedTgt = append(oneReleasedTgt, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/releases"), Target: releasedTgt}) + + // 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}) + } + + matchedSigRows := matchReleasedSignatures(oneReleasedTgt) + assert.Len(t, matchedSigRows, 1) + + outputRow := matchedSigRows[0] + // Empty signers because "targets/releases" doesn't show up + assert.Empty(t, outputRow.Signers) + assert.Equal(t, releasedTgt.Name, outputRow.TagName) + assert.Equal(t, hex.EncodeToString(releasedTgt.Hashes[notary.SHA256]), outputRow.HashHex) +} + +func TestMatchOneReleasedMultiSignature(t *testing.T) { + // now try only 1 "released" target with 3 additional sigs, 1 row will appear with 3 signers + oneReleasedTgt := []client.TargetSignedStruct{} + + // make and append the "released" target to our mock input + releasedTgt := client.Target{Name: "released", Hashes: data.Hashes{notary.SHA256: []byte("released-hash")}} + oneReleasedTgt = append(oneReleasedTgt, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/releases"), Target: releasedTgt}) + + // make and append 3 non-released signatures on both the "released" and "unreleased" targets + 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}) + oneReleasedTgt = append(oneReleasedTgt, client.TargetSignedStruct{Role: mockDelegationRoleWithName(unreleasedRole), Target: releasedTgt}) + } + + matchedSigRows := matchReleasedSignatures(oneReleasedTgt) + assert.Len(t, matchedSigRows, 1) + + outputRow := matchedSigRows[0] + // We should have three signers + assert.Equal(t, outputRow.Signers, []string{"a", "b", "c"}) + assert.Equal(t, releasedTgt.Name, outputRow.TagName) + assert.Equal(t, hex.EncodeToString(releasedTgt.Hashes[notary.SHA256]), outputRow.HashHex) +} + +func TestMatchMultiReleasedMultiSignature(t *testing.T) { + // now try 3 "released" targets with additional sigs to show 3 rows as follows: + // target-a is signed by targets/releases and targets/a - a will be the signer + // target-b is signed by targets/releases, targets/a, targets/b - a and b will be the signers + // target-c is signed by targets/releases, targets/a, targets/b, targets/c - a, b, and c will be the signers + multiReleasedTgts := []client.TargetSignedStruct{} + // make target-a, target-b, and target-c + targetA := client.Target{Name: "target-a", Hashes: data.Hashes{notary.SHA256: []byte("target-a-hash")}} + targetB := client.Target{Name: "target-b", Hashes: data.Hashes{notary.SHA256: []byte("target-b-hash")}} + targetC := client.Target{Name: "target-c", Hashes: data.Hashes{notary.SHA256: []byte("target-c-hash")}} + + // have targets/releases "sign" on all of these targets so they are released + multiReleasedTgts = append(multiReleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/releases"), Target: targetA}) + multiReleasedTgts = append(multiReleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/releases"), Target: targetB}) + multiReleasedTgts = append(multiReleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/releases"), Target: targetC}) + + // targets/a signs off on all three targets (target-a, target-b, target-c): + for _, tgt := range []client.Target{targetA, targetB, targetC} { + multiReleasedTgts = append(multiReleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/a"), Target: tgt}) + } + + // targets/b signs off on the final two targets (target-b, target-c): + for _, tgt := range []client.Target{targetB, targetC} { + multiReleasedTgts = append(multiReleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/b"), Target: tgt}) + } + + // targets/c only signs off on the last target (target-c): + multiReleasedTgts = append(multiReleasedTgts, client.TargetSignedStruct{Role: mockDelegationRoleWithName("targets/c"), Target: targetC}) + + matchedSigRows := matchReleasedSignatures(multiReleasedTgts) + assert.Len(t, matchedSigRows, 3) + + // note that the output is sorted by tag name, so we can reliably index to validate data: + outputTargetA := matchedSigRows[0] + assert.Equal(t, outputTargetA.Signers, []string{"a"}) + assert.Equal(t, targetA.Name, outputTargetA.TagName) + assert.Equal(t, hex.EncodeToString(targetA.Hashes[notary.SHA256]), outputTargetA.HashHex) + + outputTargetB := matchedSigRows[1] + assert.Equal(t, outputTargetB.Signers, []string{"a", "b"}) + assert.Equal(t, targetB.Name, outputTargetB.TagName) + assert.Equal(t, hex.EncodeToString(targetB.Hashes[notary.SHA256]), outputTargetB.HashHex) + + outputTargetC := matchedSigRows[2] + assert.Equal(t, outputTargetC.Signers, []string{"a", "b", "c"}) + assert.Equal(t, targetC.Name, outputTargetC.TagName) + assert.Equal(t, hex.EncodeToString(targetC.Hashes[notary.SHA256]), outputTargetC.HashHex) +} + +func TestMatchReleasedSignatureFromTargets(t *testing.T) { + // now try only 1 "released" target with no additional sigs, one rows will appear + oneReleasedTgt := []client.TargetSignedStruct{} + // make and append the "released" target to our mock input + releasedTgt := client.Target{Name: "released", Hashes: data.Hashes{notary.SHA256: []byte("released-hash")}} + oneReleasedTgt = append(oneReleasedTgt, client.TargetSignedStruct{Role: mockDelegationRoleWithName(data.CanonicalTargetsRole.String()), Target: releasedTgt}) + matchedSigRows := matchReleasedSignatures(oneReleasedTgt) + assert.Len(t, matchedSigRows, 1) + outputRow := matchedSigRows[0] + // Empty signers because "targets" doesn't show up + assert.Empty(t, outputRow.Signers) + assert.Equal(t, releasedTgt.Name, outputRow.TagName) + assert.Equal(t, hex.EncodeToString(releasedTgt.Hashes[notary.SHA256]), outputRow.HashHex) +} + +func TestGetSignerAndAdminRolesWithKeyIDs(t *testing.T) { + roles := []data.Role{ + { + RootRole: data.RootRole{ + KeyIDs: []string{"key11"}, + }, + Name: "targets/alice", + }, + { + RootRole: data.RootRole{ + KeyIDs: []string{"key21", "key22"}, + }, + Name: "targets/releases", + }, + { + RootRole: data.RootRole{ + KeyIDs: []string{"key31"}, + }, + Name: data.CanonicalTargetsRole, + }, + { + RootRole: data.RootRole{ + KeyIDs: []string{"key41", "key01"}, + }, + Name: data.CanonicalRootRole, + }, + { + RootRole: data.RootRole{ + KeyIDs: []string{"key51"}, + }, + Name: data.CanonicalSnapshotRole, + }, + { + RootRole: data.RootRole{ + KeyIDs: []string{"key61"}, + }, + Name: data.CanonicalTimestampRole, + }, + { + RootRole: data.RootRole{ + KeyIDs: []string{"key71", "key72"}, + }, + Name: "targets/bob", + }, + } + expectedSignerRoleToKeyIDs := map[string][]string{ + "alice": {"key11"}, + "bob": {"key71", "key72"}, + } + expectedAdminRoleToKeyIDs := map[string]string{ + "Root Key": "key01, key41", + "Repository Key": "key31", + } + + var roleWithSigs []client.RoleWithSignatures + for _, role := range roles { + roleWithSig := client.RoleWithSignatures{Role: role, Signatures: nil} + roleWithSigs = append(roleWithSigs, roleWithSig) + } + signerRoleToKeyIDs := getDelegationRoleToKeyMap(roles) + assert.Equal(t, expectedSignerRoleToKeyIDs, signerRoleToKeyIDs) + adminRoleToKeyIDs := getAdministrativeRolesToKeyMap(roleWithSigs) + assert.Equal(t, expectedAdminRoleToKeyIDs, adminRoleToKeyIDs) +}