diff --git a/client/client.go b/client/client.go index cc6395679b..014139fb3c 100644 --- a/client/client.go +++ b/client/client.go @@ -352,7 +352,7 @@ func (r *NotaryRepository) AddDelegation(name string, threshold int, // the repository when the changelist gets applied at publish time. // This does not validate that the delegation exists, since one might exist // after applying all changes. -func (r *NotaryRepository) RemoveDelegation(name string) error { +func (r *NotaryRepository) RemoveDelegation(name string, keyIDs, paths []string, removeAll bool) error { if !data.IsDelegation(name) { return data.ErrInvalidRole{Role: name, Reason: "invalid delegation role name"} @@ -365,14 +365,35 @@ func (r *NotaryRepository) RemoveDelegation(name string) error { defer cl.Close() logrus.Debugf(`Removing delegation "%s"\n`, name) + var template *changelist.TufChange - template := changelist.NewTufChange( - changelist.ActionDelete, - name, - changelist.TypeTargetsDelegation, - "", // no path - nil, - ) + // We use the Delete action only for force removal, Update is used for removing individual keys and paths + if removeAll { + template = changelist.NewTufChange( + changelist.ActionDelete, + name, + changelist.TypeTargetsDelegation, + "", // no path + nil, // deleting role, no data needed + ) + + } else { + tdJSON, err := json.Marshal(&changelist.TufDelegation{ + RemoveKeys: keyIDs, + RemovePaths: paths, + }) + if err != nil { + return err + } + + template = changelist.NewTufChange( + changelist.ActionUpdate, + name, + changelist.TypeTargetsDelegation, + "", // no path + tdJSON, + ) + } return addChange(cl, template, name) } @@ -514,6 +535,45 @@ func (r *NotaryRepository) GetChangelist() (changelist.Changelist, error) { return cl, nil } +// GetDelegationRoles returns the keys and roles of the repository's delegations +func (r *NotaryRepository) GetDelegationRoles() ([]*data.Role, error) { + // Update state of the repo to latest + if _, err := r.Update(false); err != nil { + return nil, err + } + + // All top level delegations (ex: targets/level1) are stored exclusively in targets.json + targets, ok := r.tufRepo.Targets[data.CanonicalTargetsRole] + if !ok { + return nil, store.ErrMetaNotFound{data.CanonicalTargetsRole} + } + + allDelegations := targets.Signed.Delegations.Roles + + // make a copy for traversing nested delegations + delegationsList := make([]*data.Role, len(allDelegations)) + copy(delegationsList, allDelegations) + + // Now traverse to lower level delegations (ex: targets/level1/level2) + for len(delegationsList) > 0 { + // Pop off first delegation to traverse + delegation := delegationsList[0] + delegationsList = delegationsList[1:] + + // Get metadata + delegationMeta, ok := r.tufRepo.Targets[delegation.Name] + // If we get an error, don't try to traverse further into this subtree because it doesn't exist or is malformed + if !ok { + continue + } + + // Add nested delegations to return list and exploration list + allDelegations = append(allDelegations, delegationMeta.Signed.Delegations.Roles...) + delegationsList = append(delegationsList, delegationMeta.Signed.Delegations.Roles...) + } + return allDelegations, nil +} + // Publish pushes the local changes in signed material to the remote notary-server // Conceptually it performs an operation similar to a `git rebase` func (r *NotaryRepository) Publish() error { diff --git a/client/client_test.go b/client/client_test.go index e83888f27d..f88dcc4112 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2312,7 +2312,7 @@ func TestPublishRemoveDelgation(t *testing.T) { assert.NoError(t, delgRepo.Publish()) // owner removes delegation - assert.NoError(t, ownerRepo.RemoveDelegation("targets/a")) + assert.NoError(t, ownerRepo.RemoveDelegation("targets/a", []string{aKey.ID()}, []string{}, false)) assert.NoError(t, ownerRepo.Publish()) // delegated repo can now no longer publish to delegated role @@ -2654,23 +2654,22 @@ func TestRemoveDelegationChangefileValid(t *testing.T) { rootPubKey := repo.CryptoService.GetKey(rootKeyID) assert.NotNil(t, rootPubKey) - err := repo.RemoveDelegation("root") + err := repo.RemoveDelegation("root", []string{rootKeyID}, []string{}, false) assert.Error(t, err) assert.IsType(t, data.ErrInvalidRole{}, err) assert.Empty(t, getChanges(t, repo)) // to demonstrate that so long as the delegation name is valid, the // existence of the delegation doesn't matter - assert.NoError(t, repo.RemoveDelegation("targets/a/b/c")) + assert.NoError(t, repo.RemoveDelegation("targets/a/b/c", []string{rootKeyID}, []string{}, false)) // ensure that the changefile is correct changes := getChanges(t, repo) assert.Len(t, changes, 1) - assert.Equal(t, changelist.ActionDelete, changes[0].Action()) + assert.Equal(t, changelist.ActionUpdate, changes[0].Action()) assert.Equal(t, "targets/a/b/c", changes[0].Scope()) assert.Equal(t, changelist.TypeTargetsDelegation, changes[0].Type()) assert.Equal(t, "", changes[0].Path()) - assert.Empty(t, changes[0].Content()) } // The changefile produced by RemoveDelegation, when applied, actually removes @@ -2697,7 +2696,7 @@ func TestRemoveDelegationChangefileApplicable(t *testing.T) { assert.Len(t, targetRole.Signed.Delegations.Keys, 1) // now remove it - assert.NoError(t, repo.RemoveDelegation("targets/a")) + assert.NoError(t, repo.RemoveDelegation("targets/a", []string{rootKeyID}, []string{}, false)) changes = getChanges(t, repo) assert.Len(t, changes, 2) assert.NoError(t, applyTargetsChange(repo.tufRepo, changes[1])) @@ -2711,7 +2710,7 @@ func TestRemoveDelegationChangefileApplicable(t *testing.T) { // file to be propagated. func TestRemoveDelegationErrorWritingChanges(t *testing.T) { testErrorWritingChangefiles(t, func(repo *NotaryRepository) error { - return repo.RemoveDelegation("targets/a") + return repo.RemoveDelegation("targets/a", []string{""}, []string{}, false) }) } diff --git a/client/helpers.go b/client/helpers.go index 304ac3d621..a9fd590a9f 100644 --- a/client/helpers.go +++ b/client/helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "path" + "strings" "time" "github.com/Sirupsen/logrus" @@ -85,13 +86,13 @@ func changeTargetsDelegation(repo *tuf.Repo, c changelist.Change) error { return err } if err == nil { - // role existed - return data.ErrInvalidRole{ - Role: c.Scope(), - Reason: "cannot create a role that already exists", + // role existed, attempt to merge paths and keys + if err := r.AddPaths(td.AddPaths); err != nil { + return err } + return repo.UpdateDelegations(r, td.AddKeys) } - // role doesn't exist, create brand new + // create brand new role r, err = td.ToNewRole(c.Scope()) if err != nil { return err @@ -107,7 +108,12 @@ func changeTargetsDelegation(repo *tuf.Repo, c changelist.Change) error { if err != nil { return err } - // role exists, merge + // If we specify the only keys left delete the role, else just delete specified keys + if strings.Join(r.KeyIDs, ";") == strings.Join(td.RemoveKeys, ";") && len(td.AddKeys) == 0 { + r := data.Role{Name: c.Scope()} + return repo.DeleteDelegation(r) + } + // if we aren't deleting and the role exists, merge if err := r.AddPaths(td.AddPaths); err != nil { return err } diff --git a/client/helpers_test.go b/client/helpers_test.go index b9c9226a39..04a5e7f13a 100644 --- a/client/helpers_test.go +++ b/client/helpers_test.go @@ -225,12 +225,18 @@ func TestApplyTargetsDelegationCreateDelete(t *testing.T) { assert.Equal(t, "level1", role.Paths[0]) // delete delegation + td = &changelist.TufDelegation{ + RemoveKeys: []string{newKey.ID()}, + } + + tdJSON, err = json.Marshal(td) + assert.NoError(t, err) ch = changelist.NewTufChange( changelist.ActionDelete, "targets/level1", changelist.TypeTargetsDelegation, "", - nil, + tdJSON, ) err = applyTargetsChange(repo, ch) @@ -307,13 +313,18 @@ func TestApplyTargetsDelegationCreate2SharedKey(t *testing.T) { assert.Equal(t, "targets/level2", role2.Name) assert.Equal(t, "level2", role2.Paths[0]) - // delete one delegation, ensure key remains + // delete one delegation, ensure shared key remains + td = &changelist.TufDelegation{ + RemoveKeys: []string{newKey.ID()}, + } + tdJSON, err = json.Marshal(td) + assert.NoError(t, err) ch = changelist.NewTufChange( changelist.ActionDelete, "targets/level1", changelist.TypeTargetsDelegation, "", - nil, + tdJSON, ) err = applyTargetsChange(repo, ch) @@ -328,7 +339,7 @@ func TestApplyTargetsDelegationCreate2SharedKey(t *testing.T) { "targets/level2", changelist.TypeTargetsDelegation, "", - nil, + tdJSON, ) err = applyTargetsChange(repo, ch) @@ -468,11 +479,91 @@ func TestApplyTargetsDelegationCreateAlreadyExisting(t *testing.T) { // we have sufficient checks elsewhere we don't need to confirm that // creating fresh works here via more asserts. - // when attempting to create the same role again, assert we receive - // an ErrInvalidRole because an existing role can't be "created" + extraKey, err := cs.Create("targets/level1", data.ED25519Key) + assert.NoError(t, err) + + // create delegation + kl = data.KeyList{extraKey} + td = &changelist.TufDelegation{ + NewThreshold: 1, + AddKeys: kl, + AddPaths: []string{"level1"}, + } + + tdJSON, err = json.Marshal(td) + assert.NoError(t, err) + + ch = changelist.NewTufChange( + changelist.ActionCreate, + "targets/level1", + changelist.TypeTargetsDelegation, + "", + tdJSON, + ) + + // when attempting to create the same role again, check that we added a key err = applyTargetsChange(repo, ch) - assert.Error(t, err) - assert.IsType(t, data.ErrInvalidRole{}, err) + assert.NoError(t, err) + delegation, err := repo.GetDelegation("targets/level1") + assert.NoError(t, err) + assert.Contains(t, delegation.Paths, "level1") + assert.Equal(t, len(delegation.KeyIDs), 2) +} + +func TestApplyTargetsDelegationAlreadyExistingMergePaths(t *testing.T) { + _, repo, cs, err := testutils.EmptyRepo("docker.com/notary") + assert.NoError(t, err) + + newKey, err := cs.Create("targets/level1", data.ED25519Key) + assert.NoError(t, err) + + // create delegation + kl := data.KeyList{newKey} + td := &changelist.TufDelegation{ + NewThreshold: 1, + AddKeys: kl, + AddPaths: []string{"level1"}, + } + + tdJSON, err := json.Marshal(td) + assert.NoError(t, err) + + ch := changelist.NewTufChange( + changelist.ActionCreate, + "targets/level1", + changelist.TypeTargetsDelegation, + "", + tdJSON, + ) + + err = applyTargetsChange(repo, ch) + assert.NoError(t, err) + // we have sufficient checks elsewhere we don't need to confirm that + // creating fresh works here via more asserts. + + // Use different path for this changelist + td.AddPaths = []string{"level2"} + + tdJSON, err = json.Marshal(td) + assert.NoError(t, err) + + ch = changelist.NewTufChange( + changelist.ActionCreate, + "targets/level1", + changelist.TypeTargetsDelegation, + "", + tdJSON, + ) + + // when attempting to create the same role again, check that we + // merged with previous details + err = applyTargetsChange(repo, ch) + assert.NoError(t, err) + delegation, err := repo.GetDelegation("targets/level1") + assert.NoError(t, err) + // Assert we have both paths + assert.Contains(t, delegation.Paths, "level2") + assert.Contains(t, delegation.Paths, "level1") } func TestApplyTargetsDelegationInvalidRole(t *testing.T) { diff --git a/cmd/notary/delegations.go b/cmd/notary/delegations.go new file mode 100644 index 0000000000..f061942043 --- /dev/null +++ b/cmd/notary/delegations.go @@ -0,0 +1,212 @@ +package main + +import ( + "fmt" + "io/ioutil" + + "github.com/docker/notary" + notaryclient "github.com/docker/notary/client" + "github.com/docker/notary/passphrase" + "github.com/docker/notary/trustmanager" + "github.com/docker/notary/tuf/data" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cmdDelegationTemplate = usageTemplate{ + Use: "delegation", + Short: "Operates on delegations.", + Long: `Operations on TUF delegations.`, +} + +var cmdDelegationListTemplate = usageTemplate{ + Use: "list [ GUN ]", + Short: "Lists delegations for the Global Unique Name.", + Long: "Lists all delegations known to notary for a specific Global Unique Name.", +} + +var cmdDelegationRemoveTemplate = usageTemplate{ + Use: "remove [ GUN ] [ Role ] ...", + Short: "Remove KeyID(s) from the specified Role delegation.", + Long: "Remove KeyID(s) from the specified Role delegation in a specific Global Unique Name.", +} + +var cmdDelegationAddTemplate = usageTemplate{ + Use: "add [ GUN ] [ Role ] ...", + Short: "Add a keys to delegation using the provided public key certificate PEMs.", + Long: "Add a keys to delegation using the provided public key certificate PEMs in a specific Global Unique Name.", +} + +var paths []string +var removeAll, removeYes bool + +type delegationCommander struct { + // these need to be set + configGetter func() *viper.Viper + retriever passphrase.Retriever +} + +func (d *delegationCommander) GetCommand() *cobra.Command { + cmd := cmdDelegationTemplate.ToCommand(nil) + cmd.AddCommand(cmdDelegationListTemplate.ToCommand(d.delegationsList)) + + cmdRemDelg := cmdDelegationRemoveTemplate.ToCommand(d.delegationRemove) + cmdRemDelg.Flags().StringSliceVar(&paths, "paths", nil, "List of paths to remove") + cmdRemDelg.Flags().BoolVarP(&removeYes, "yes", "y", false, "Answer yes to the removal question (no confirmation)") + cmd.AddCommand(cmdRemDelg) + + cmdAddDelg := cmdDelegationAddTemplate.ToCommand(d.delegationAdd) + cmdAddDelg.Flags().StringSliceVar(&paths, "paths", nil, "List of paths to add") + cmd.AddCommand(cmdAddDelg) + return cmd +} + +// delegationsList lists all the delegations for a particular GUN +func (d *delegationCommander) delegationsList(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf( + "Please provide a Global Unique Name as an argument to list") + } + + config := d.configGetter() + + gun := args[0] + + // initialize repo with transport to get latest state of the world before listing delegations + nRepo, err := notaryclient.NewNotaryRepository(config.GetString("trust_dir"), gun, getRemoteTrustServer(config), getTransport(config, gun, true), retriever) + if err != nil { + return err + } + + delegationRoles, err := nRepo.GetDelegationRoles() + if err != nil { + return fmt.Errorf("Error retrieving delegation roles for repository %s: %v", gun, err) + } + + cmd.Println("") + prettyPrintRoles(delegationRoles, cmd.Out()) + cmd.Println("") + return nil +} + +// delegationRemove removes a public key from a specific role in a GUN +func (d *delegationCommander) delegationRemove(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return fmt.Errorf("must specify the Global Unique Name and the role of the delegation along with optional keyIDs and/or a list of paths to remove") + } + + config := d.configGetter() + + gun := args[0] + role := args[1] + + // If we're only given the gun and the role, attempt to remove all data for this delegation + if len(args) == 2 && paths == nil { + removeAll = true + } + + keyIDs := []string{} + // Change nil paths to empty slice for TUF + if paths == nil { + paths = []string{} + } + + if len(args) > 2 { + keyIDs = args[2:] + } + + // no online operations are performed by add so the transport argument + // should be nil + nRepo, err := notaryclient.NewNotaryRepository(config.GetString("trust_dir"), gun, getRemoteTrustServer(config), nil, retriever) + if err != nil { + return err + } + + if removeAll { + cmd.Println("\nAre you sure you want to remove all data for this delegation? (yes/no)") + // Ask for confirmation before force removing delegation + if !removeYes { + confirmed := askConfirm() + if !confirmed { + fatalf("Aborting action.") + } + } else { + cmd.Println("Confirmed `yes` from flag") + } + } + + // Remove the delegation from the repository + err = nRepo.RemoveDelegation(role, keyIDs, paths, removeAll) + if err != nil { + return fmt.Errorf("failed to remove delegation: %v", err) + } + cmd.Println("") + if removeAll { + cmd.Printf("Forced removal (including all keys and paths) of delegation role %s to repository \"%s\" staged for next publish.\n", role, gun) + } + cmd.Printf( + "Removal of delegation role %s with keys %s and paths %s, to repository \"%s\" staged for next publish.\n", + role, keyIDs, paths, gun) + cmd.Println("") + + return nil +} + +// delegationAdd creates a new delegation by adding a public key from a certificate to a specific role in a GUN +func (d *delegationCommander) delegationAdd(cmd *cobra.Command, args []string) error { + if len(args) < 2 || len(args) < 3 && paths == nil { + return fmt.Errorf("must specify the Global Unique Name and the role of the delegation along with the public key certificate paths and/or a list of paths to add") + } + + config := d.configGetter() + + gun := args[0] + role := args[1] + + pubKeys := []data.PublicKey{} + if len(args) > 2 { + pubKeyPaths := args[2:] + for _, pubKeyPath := range pubKeyPaths { + // Read public key bytes from PEM file + pubKeyBytes, err := ioutil.ReadFile(pubKeyPath) + if err != nil { + return fmt.Errorf("unable to read public key from file: %s", pubKeyPath) + } + + // Parse PEM bytes into type PublicKey + pubKey, err := trustmanager.ParsePEMPublicKey(pubKeyBytes) + if err != nil { + return fmt.Errorf("unable to parse valid public key certificate from PEM file %s: %v", pubKeyPath, err) + } + pubKeys = append(pubKeys, pubKey) + } + } + + // no online operations are performed by add so the transport argument + // should be nil + nRepo, err := notaryclient.NewNotaryRepository(config.GetString("trust_dir"), gun, getRemoteTrustServer(config), nil, retriever) + if err != nil { + return err + } + + // Add the delegation to the repository + // Sets threshold to 1 since we only added one key - thresholds are not currently fully supported, though + // one can use additional client-side validation to check for signatures from a quorum of varying delegation roles + err = nRepo.AddDelegation(role, notary.MinThreshold, pubKeys, paths) + if err != nil { + return fmt.Errorf("failed to create delegation: %v", err) + } + + // Make keyID slice for better CLI print + pubKeyIDs := []string{} + for _, pubKey := range pubKeys { + pubKeyIDs = append(pubKeyIDs, pubKey.ID()) + } + + cmd.Println("") + cmd.Printf( + "Addition of delegation role %s with keys %s and paths %s, to repository \"%s\" staged for next publish.\n", + role, pubKeyIDs, paths, gun) + cmd.Println("") + return nil +} diff --git a/cmd/notary/delegations_test.go b/cmd/notary/delegations_test.go new file mode 100644 index 0000000000..9550965477 --- /dev/null +++ b/cmd/notary/delegations_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "crypto/rand" + "crypto/x509" + "github.com/docker/notary/cryptoservice" + "github.com/docker/notary/trustmanager" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "io/ioutil" + "os" + "testing" + "time" +) + +var testTrustDir = "trust_dir" + +func setup() *delegationCommander { + return &delegationCommander{ + configGetter: func() *viper.Viper { + mainViper := viper.New() + mainViper.Set("trust_dir", testTrustDir) + return mainViper + }, + retriever: nil, + } +} + +func TestAddInvalidDelegationName(t *testing.T) { + // Cleanup after test + defer os.RemoveAll(testTrustDir) + + // Setup certificate + tempFile, err := ioutil.TempFile("/tmp", "pemfile") + assert.NoError(t, err) + cert, _, err := generateValidTestCert() + _, err = tempFile.Write(trustmanager.CertToPEM(cert)) + assert.NoError(t, err) + tempFile.Close() + defer os.Remove(tempFile.Name()) + + // Setup commander + commander := setup() + + // Should error due to invalid delegation name (should be prefixed by "targets/") + err = commander.delegationAdd(commander.GetCommand(), []string{"gun", "INVALID_NAME", tempFile.Name()}) + assert.Error(t, err) +} + +func TestAddInvalidDelegationCert(t *testing.T) { + // Cleanup after test + defer os.RemoveAll(testTrustDir) + + // Setup certificate + tempFile, err := ioutil.TempFile("/tmp", "pemfile") + assert.NoError(t, err) + cert, _, err := generateExpiredTestCert() + _, err = tempFile.Write(trustmanager.CertToPEM(cert)) + assert.NoError(t, err) + tempFile.Close() + defer os.Remove(tempFile.Name()) + + // Setup commander + commander := setup() + + // Should error due to expired cert + err = commander.delegationAdd(commander.GetCommand(), []string{"gun", "targets/delegation", tempFile.Name(), "--paths", "path"}) + assert.Error(t, err) +} + +func TestRemoveInvalidDelegationName(t *testing.T) { + // Cleanup after test + defer os.RemoveAll(testTrustDir) + + // Setup commander + commander := setup() + + // Should error due to invalid delegation name (should be prefixed by "targets/") + err := commander.delegationRemove(commander.GetCommand(), []string{"gun", "INVALID_NAME", "fake_key_id1", "fake_key_id2"}) + assert.Error(t, err) +} + +func TestAddInvalidNumArgs(t *testing.T) { + // Setup commander + commander := setup() + + // Should error due to invalid number of args (2 instead of 3) + err := commander.delegationAdd(commander.GetCommand(), []string{"not", "enough"}) + assert.Error(t, err) +} + +func TestListInvalidNumArgs(t *testing.T) { + // Setup commander + commander := setup() + + // Should error due to invalid number of args (0 instead of 1) + err := commander.delegationsList(commander.GetCommand(), []string{}) + assert.Error(t, err) +} + +func TestRemoveInvalidNumArgs(t *testing.T) { + // Setup commander + commander := setup() + + // Should error due to invalid number of args (1 instead of 2) + err := commander.delegationRemove(commander.GetCommand(), []string{"notenough"}) + assert.Error(t, err) +} + +func generateValidTestCert() (*x509.Certificate, string, error) { + privKey, err := trustmanager.GenerateECDSAKey(rand.Reader) + if err != nil { + return nil, "", err + } + keyID := privKey.ID() + startTime := time.Now() + endTime := startTime.AddDate(10, 0, 0) + cert, err := cryptoservice.GenerateCertificate(privKey, "gun", startTime, endTime) + if err != nil { + return nil, "", err + } + return cert, keyID, nil +} + +func generateExpiredTestCert() (*x509.Certificate, string, error) { + privKey, err := trustmanager.GenerateECDSAKey(rand.Reader) + if err != nil { + return nil, "", err + } + keyID := privKey.ID() + // Set to Unix time 0 start time, valid for one more day + startTime := time.Unix(0, 0) + endTime := startTime.AddDate(0, 0, 1) + cert, err := cryptoservice.GenerateCertificate(privKey, "gun", startTime, endTime) + if err != nil { + return nil, "", err + } + return cert, keyID, nil +} diff --git a/cmd/notary/integration_test.go b/cmd/notary/integration_test.go index 1c4b732e28..b25c7dc9d9 100644 --- a/cmd/notary/integration_test.go +++ b/cmd/notary/integration_test.go @@ -145,6 +145,223 @@ func TestClientTufInteraction(t *testing.T) { assert.False(t, strings.Contains(string(output), target)) } +// Initialize repo and test delegations commands by adding, listing, and removing delegations +func TestClientDelegationsInteraction(t *testing.T) { + setUp(t) + + tempDir := tempDirWithConfig(t, "{}") + defer os.RemoveAll(tempDir) + + server := setupServer() + defer server.Close() + + // Setup certificate + tempFile, err := ioutil.TempFile("/tmp", "pemfile") + assert.NoError(t, err) + + privKey, err := trustmanager.GenerateECDSAKey(rand.Reader) + startTime := time.Now() + endTime := startTime.AddDate(10, 0, 0) + cert, err := cryptoservice.GenerateCertificate(privKey, "gun", startTime, endTime) + assert.NoError(t, err) + + _, err = tempFile.Write(trustmanager.CertToPEM(cert)) + assert.NoError(t, err) + tempFile.Close() + defer os.Remove(tempFile.Name()) + + rawPubBytes, _ := ioutil.ReadFile(tempFile.Name()) + parsedPubKey, _ := trustmanager.ParsePEMPublicKey(rawPubBytes) + keyID := parsedPubKey.ID() + + var output string + + // -- tests -- + + // init repo + _, err = runCommand(t, tempDir, "-s", server.URL, "init", "gun") + assert.NoError(t, err) + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - none yet + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "No such roles published in this repository.") + + // add new valid delegation with single new cert + output, err = runCommand(t, tempDir, "delegation", "add", "gun", "targets/delegation", tempFile.Name()) + assert.NoError(t, err) + assert.Contains(t, output, "Addition of delegation role") + + // check status - see delegation + output, err = runCommand(t, tempDir, "status", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "Unpublished changes for gun") + + // list delegations - none yet because still unpublished + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "No such roles published in this repository.") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // check status - no changelist + output, err = runCommand(t, tempDir, "status", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "No unpublished changes for gun") + + // list delegations - we should see our added delegation + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "targets/delegation") + + // Setup another certificate + tempFile2, err := ioutil.TempFile("/tmp", "pemfile2") + assert.NoError(t, err) + + privKey, err = trustmanager.GenerateECDSAKey(rand.Reader) + startTime = time.Now() + endTime = startTime.AddDate(10, 0, 0) + cert, err = cryptoservice.GenerateCertificate(privKey, "gun", startTime, endTime) + assert.NoError(t, err) + + _, err = tempFile2.Write(trustmanager.CertToPEM(cert)) + assert.NoError(t, err) + assert.NoError(t, err) + tempFile2.Close() + defer os.Remove(tempFile2.Name()) + + rawPubBytes2, _ := ioutil.ReadFile(tempFile2.Name()) + parsedPubKey2, _ := trustmanager.ParsePEMPublicKey(rawPubBytes2) + keyID2 := parsedPubKey2.ID() + + // add to the delegation by specifying the same role, this time add a path + output, err = runCommand(t, tempDir, "delegation", "add", "gun", "targets/delegation", tempFile2.Name(), "--paths", "path") + assert.NoError(t, err) + assert.Contains(t, output, "Addition of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see two keys + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, ",") + assert.Contains(t, output, "path") + + // remove the delegation's first key + output, err = runCommand(t, tempDir, "delegation", "remove", "gun", "targets/delegation", keyID) + assert.NoError(t, err) + assert.Contains(t, output, "Removal of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see the delegation but with only the second key + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.NotContains(t, output, ",") + + // remove the delegation's second key + output, err = runCommand(t, tempDir, "delegation", "remove", "gun", "targets/delegation", keyID2) + assert.NoError(t, err) + assert.Contains(t, output, "Removal of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see no delegations + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "No such roles published in this repository.") + + // add delegation with multiple certs and multiple paths + output, err = runCommand(t, tempDir, "delegation", "add", "gun", "targets/delegation", tempFile.Name(), tempFile2.Name(), "--paths", "path1,path2") + assert.NoError(t, err) + assert.Contains(t, output, "Addition of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see two keys + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, ",") + assert.Contains(t, output, "path1,path2") + + // add delegation with multiple certs and multiple paths + output, err = runCommand(t, tempDir, "delegation", "add", "gun", "targets/delegation", "--paths", "path3") + assert.NoError(t, err) + assert.Contains(t, output, "Addition of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see two keys + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, ",") + assert.Contains(t, output, "path1,path2,path3") + + // just remove two paths from this delegation + output, err = runCommand(t, tempDir, "delegation", "remove", "gun", "targets/delegation", "--paths", "path2,path3") + assert.NoError(t, err) + assert.Contains(t, output, "Removal of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see the same two keys, and only path1 + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, ",") + assert.Contains(t, output, "path1") + assert.NotContains(t, output, "path2") + assert.NotContains(t, output, "path3") + + // remove the remaining path, should not remove the delegation entirely + output, err = runCommand(t, tempDir, "delegation", "remove", "gun", "targets/delegation", "--paths", "path1") + assert.NoError(t, err) + assert.Contains(t, output, "Removal of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see the same two keys, and no paths + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, ",") + assert.NotContains(t, output, "path1") + assert.NotContains(t, output, "path2") + assert.NotContains(t, output, "path3") + + // remove by force to delete the delegation entirely + output, err = runCommand(t, tempDir, "delegation", "remove", "gun", "targets/delegation", "-y") + assert.NoError(t, err) + assert.Contains(t, output, "Removal of delegation role") + + // publish repo + _, err = runCommand(t, tempDir, "-s", server.URL, "publish", "gun") + assert.NoError(t, err) + + // list delegations - we should see no delegations + output, err = runCommand(t, tempDir, "-s", server.URL, "delegation", "list", "gun") + assert.NoError(t, err) + assert.Contains(t, output, "No such roles published in this repository.") +} + // Splits a string into lines, and returns any lines that are not empty ( // striped of whitespace) func splitLines(chunk string) []string { diff --git a/cmd/notary/main.go b/cmd/notary/main.go index ca44997857..1065204ee9 100644 --- a/cmd/notary/main.go +++ b/cmd/notary/main.go @@ -119,7 +119,13 @@ func setupCommand(notaryCmd *cobra.Command) { retriever: retriever, } + cmdDelegationGenerator := &delegationCommander{ + configGetter: parseConfig, + retriever: retriever, + } + notaryCmd.AddCommand(cmdKeyGenerator.GetCommand()) + notaryCmd.AddCommand(cmdDelegationGenerator.GetCommand()) notaryCmd.AddCommand(cmdCert) notaryCmd.AddCommand(cmdTufInit) notaryCmd.AddCommand(cmdTufList) diff --git a/cmd/notary/prettyprint.go b/cmd/notary/prettyprint.go index 13b2a7e32a..3cb870fb07 100644 --- a/cmd/notary/prettyprint.go +++ b/cmd/notary/prettyprint.go @@ -8,6 +8,7 @@ import ( "math" "path/filepath" "sort" + "strings" "time" "github.com/docker/notary/client" @@ -142,8 +143,17 @@ func (t targetsSorter) Less(i, j int) bool { return t[i].Name < t[j].Name } -// Given a list of KeyStores in order of listing preference, pretty-prints the -// root keys and then the signing keys. +// --- pretty printing roles --- + +type roleSorter []*data.Role + +func (r roleSorter) Len() int { return len(r) } +func (r roleSorter) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r roleSorter) Less(i, j int) bool { + return r[i].Name < r[j].Name +} + +// Pretty-prints the sorted list of TargetWithRoles. func prettyPrintTargets(ts []*client.TargetWithRole, writer io.Writer) { if len(ts) == 0 { writer.Write([]byte("\nNo targets present in this repository.\n\n")) @@ -165,6 +175,29 @@ func prettyPrintTargets(ts []*client.TargetWithRole, writer io.Writer) { table.Render() } +// Pretty-prints the list of provided Roles +func prettyPrintRoles(rs []*data.Role, writer io.Writer) { + if len(rs) == 0 { + writer.Write([]byte("\nNo such roles published in this repository.\n\n")) + return + } + + // this sorter works for Role types + sort.Stable(roleSorter(rs)) + + table := getTable([]string{"Role", "Paths", "Key IDs", "Threshold"}, writer) + + for _, r := range rs { + table.Append([]string{ + r.Name, + strings.Join(r.Paths, ","), + strings.Join(r.KeyIDs, ","), + fmt.Sprintf("%v", r.Threshold), + }) + } + table.Render() +} + // --- pretty printing certs --- // cert by repo name then expiry time. Don't bother sorting by fingerprint. diff --git a/cmd/notary/prettyprint_test.go b/cmd/notary/prettyprint_test.go index cd58022d70..f445703f7e 100644 --- a/cmd/notary/prettyprint_test.go +++ b/cmd/notary/prettyprint_test.go @@ -214,6 +214,58 @@ func generateCertificate(t *testing.T, gun string, expireInHours int64) *x509.Ce return cert } +// --- tests for pretty printing roles --- + +// If there are no roles, no table is printed, only a line saying that there +// are no roles. +func TestPrettyPrintZeroRoles(t *testing.T) { + var b bytes.Buffer + prettyPrintRoles([]*data.Role{}, &b) + text, err := ioutil.ReadAll(&b) + assert.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(string(text)), "\n") + assert.Len(t, lines, 1) + assert.Equal(t, "No such roles published in this repository.", lines[0]) +} + +// Roles are sorted by name, and the name, paths, and KeyIDs are printed. +func TestPrettyPrintSortedRoles(t *testing.T) { + var err error + + unsorted := []*data.Role{ + {Name: "targets/zebra", Paths: []string{"stripes", "black", "white"}, RootRole: data.RootRole{KeyIDs: []string{"101"}, Threshold: 1}}, + {Name: "targets/aardvark/unicorn/pony", Paths: []string{"rainbows"}, RootRole: data.RootRole{KeyIDs: []string{"135"}, Threshold: 1}}, + {Name: "targets/bee", Paths: []string{"honey"}, RootRole: data.RootRole{KeyIDs: []string{"246"}, Threshold: 1}}, + {Name: "targets/bee/wasp", Paths: []string{"honey/sting"}, RootRole: data.RootRole{KeyIDs: []string{"246", "468"}, Threshold: 1}}, + } + + var b bytes.Buffer + prettyPrintRoles(unsorted, &b) + text, err := ioutil.ReadAll(&b) + assert.NoError(t, err) + + expected := [][]string{ + {"targets/aardvark/unicorn/pony", "rainbows", "135", "1"}, + {"targets/bee", "honey", "246", "1"}, + {"targets/bee/wasp", "honey/sting", "246,468", "1"}, + {"targets/zebra", "stripes,black,white", "101", "1"}, + } + + lines := strings.Split(strings.TrimSpace(string(text)), "\n") + assert.Len(t, lines, len(expected)+2) + + // starts with headers + assert.True(t, reflect.DeepEqual(strings.Fields(lines[0]), strings.Fields( + "ROLE PATHS KEY IDS THRESHOLD"))) + assert.Equal(t, "----", lines[1][:4]) + + for i, line := range lines[2:] { + splitted := strings.Fields(line) + assert.Equal(t, expected[i], splitted) + } +} + // If there are no certs in the cert store store, a message that there are no // certs should be displayed. func TestPrettyPrintZeroCerts(t *testing.T) { diff --git a/const.go b/const.go index 6fbbbe47ac..db1e497d29 100644 --- a/const.go +++ b/const.go @@ -2,6 +2,8 @@ package notary // application wide constants const ( + // Require a minimum of one threshold for roles, currently we do not support a higher threshold + MinThreshold = 1 PrivKeyPerms = 0700 PubCertPerms = 0755 Sha256HexSize = 64 diff --git a/cryptoservice/crypto_service.go b/cryptoservice/crypto_service.go index f5bfa073b0..5488c8feff 100644 --- a/cryptoservice/crypto_service.go +++ b/cryptoservice/crypto_service.go @@ -69,8 +69,8 @@ func (cs *CryptoService) Create(role, algorithm string) (data.PublicKey, error) if err != nil { return nil, fmt.Errorf("failed to add key to filestore: %v", err) } - return nil, fmt.Errorf("keystores would not accept new private keys for unknown reasons") + return nil, fmt.Errorf("keystores would not accept new private keys for unknown reasons") } // GetPrivateKey returns a private key and role if present by ID. diff --git a/trustmanager/x509utils.go b/trustmanager/x509utils.go index 02e1b4f4c6..64ca1e7226 100644 --- a/trustmanager/x509utils.go +++ b/trustmanager/x509utils.go @@ -300,6 +300,44 @@ func ParsePEMPrivateKey(pemBytes []byte, passphrase string) (data.PrivateKey, er } } +// ParsePEMPublicKey returns a data.PublicKey from a PEM encoded public key or certificate. +func ParsePEMPublicKey(pubKeyBytes []byte) (data.PublicKey, error) { + pemBlock, _ := pem.Decode(pubKeyBytes) + if pemBlock == nil { + return nil, errors.New("no valid public key found") + } + + switch pemBlock.Type { + case "CERTIFICATE": + cert, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("could not parse provided certificate: %v", err) + } + err = ValidateCertificate(cert) + if err != nil { + return nil, fmt.Errorf("invalid certificate: %v", err) + } + return CertToKey(cert), nil + default: + return nil, fmt.Errorf("unsupported PEM block type %q, expected certificate", pemBlock.Type) + } +} + +// ValidateCertificate returns an error if the certificate is not valid for notary +// Currently, this is only a time expiry check +func ValidateCertificate(c *x509.Certificate) error { + if (c.NotBefore).After(c.NotAfter) { + return fmt.Errorf("certificate validity window is invalid") + } + now := time.Now() + tomorrow := now.AddDate(0, 0, 1) + // Give one day leeway on creation "before" time, check "after" against today + if (tomorrow).Before(c.NotBefore) || now.After(c.NotAfter) { + return fmt.Errorf("certificate is expired") + } + return nil +} + // GenerateRSAKey generates an RSA private key and returns a TUF PrivateKey func GenerateRSAKey(random io.Reader, bits int) (data.PrivateKey, error) { rsaPrivKey, err := rsa.GenerateKey(random, bits) diff --git a/tuf/data/roles.go b/tuf/data/roles.go index 25e9ba4572..a505c92304 100644 --- a/tuf/data/roles.go +++ b/tuf/data/roles.go @@ -2,6 +2,7 @@ package data import ( "fmt" + "github.com/Sirupsen/logrus" "path" "regexp" "strings" @@ -109,10 +110,7 @@ func NewRole(name string, threshold int, keyIDs, paths, pathHashPrefixes []strin } if IsDelegation(name) { if len(paths) == 0 && len(pathHashPrefixes) == 0 { - return nil, ErrInvalidRole{ - Role: name, - Reason: "roles with no Paths and no PathHashPrefixes will never be able to publish content", - } + logrus.Debugf("role %s with no Paths and no PathHashPrefixes will never be able to publish content until one or more are added", name) } } if threshold < 1 { diff --git a/tuf/data/roles_test.go b/tuf/data/roles_test.go index cf4bc1940f..46419e0977 100644 --- a/tuf/data/roles_test.go +++ b/tuf/data/roles_test.go @@ -63,16 +63,6 @@ func TestSubtractStrSlicesEqual(t *testing.T) { assert.Len(t, res, 0) } -func TestNewRolePathsAndHashPrefixRejection(t *testing.T) { - _, err := NewRole("targets/level1", 1, []string{"abc"}, nil, nil) - assert.Error(t, err) - assert.IsType(t, ErrInvalidRole{}, err) - - _, err = NewRole("targets/level1", 1, []string{"abc"}, []string{""}, []string{""}) - assert.Error(t, err) - assert.IsType(t, ErrInvalidRole{}, err) -} - func TestAddRemoveKeys(t *testing.T) { role, err := NewRole("targets", 1, []string{"abc"}, []string{""}, nil) assert.NoError(t, err)