From d835fbbca2739723ebfc6d814bc795801c85b43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miloslav=20Trma=C4=8D?= Date: Mon, 1 Feb 2016 03:07:38 +0100 Subject: [PATCH] Implement root certificate rotation in NotaryRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotaryRepository can now list root certificates, and generate new versions (as changelists to be applied on Publish). This is a pretty mechanical encapsulation of the root certificate rotation support in Repo.AddBaseKeys and Repo.RemoveBaseKeys. The only slightly interesting part is ListRootCert, which requires on-line access to ensure fresh data, and depends on CertStore doing some verification for us. Signed-off-by: Miloslav Trmač --- client/changelist/change.go | 6 +- client/client.go | 94 ++++++++++++++++++++++++- client/client_test.go | 132 ++++++++++++++++++++++++++++++++++++ client/helpers.go | 16 ++++- 4 files changed, 243 insertions(+), 5 deletions(-) diff --git a/client/changelist/change.go b/client/changelist/change.go index 3307189c81..27dc8add8a 100644 --- a/client/changelist/change.go +++ b/client/changelist/change.go @@ -38,8 +38,10 @@ type TufChange struct { // TufRootData represents a modification of the keys associated // with a role that appears in the root.json type TufRootData struct { - Keys data.KeyList `json:"keys"` - RoleName string `json:"role"` + ReplaceKeys data.KeyList `json:"keys"` + AddKeys data.KeyList `json:"add_keys, omitempty"` + RemoveKeys []string `json:"remove_keys,omitempty"` + RoleName string `json:"role"` } // NewTufChange initializes a tufChange object diff --git a/client/client.go b/client/client.go index 54b074377f..a3e68748c7 100644 --- a/client/client.go +++ b/client/client.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "crypto/x509" "encoding/json" "fmt" "io/ioutil" @@ -936,8 +937,8 @@ func (r *NotaryRepository) rootFileKeyChange(cl changelist.Changelist, role, act kl := make(data.KeyList, 0, 1) kl = append(kl, key) meta := changelist.TufRootData{ - RoleName: role, - Keys: kl, + RoleName: role, + ReplaceKeys: kl, } metaJSON, err := json.Marshal(meta) if err != nil { @@ -977,3 +978,92 @@ func (r *NotaryRepository) DeleteTrustData() error { } return nil } + +// ListRootCerts returns current trusted root certificates. +// (In particular, not old certificates which we have already +// rotated from but are still using for signing to allow rollover, +// even if they are still trusted locally.) +func (r *NotaryRepository) ListRootCerts() ([]*x509.Certificate, error) { + if err := r.Update(false); err != nil { + return nil, err + } + + keyIDs := r.tufRepo.Root.Signed.Roles[data.CanonicalRootRole].KeyIDs + certs := make([]*x509.Certificate, 0, len(keyIDs)) + for _, keyID := range keyIDs { + // r.tufRepo contains all data from root.json, which may include irrelevant + // certificates. This is handled by certs.ValidateRoot, which adds + // only the real trusted certificates into the store. So, this lookup by ID is not + // just a dumb way to not parse the certificate, the handling of ErrNoCertificatesFound + // ensures we only get the intersection of (certificates in current root.json) with + // (locally trusted certificates). The rotation code in certs.ValidateRoot ensures + // that we locally store a copy of all relevant certificates. + cert, err := r.CertStore.GetCertificateByCertID(keyID) + if err == nil { + certs = append(certs, cert) + } else if _, ok := err.(*trustmanager.ErrNoCertificatesFound); !ok { + return nil, err + } + } + return certs, nil +} + +// RotateRootCert generates a new certificate to replace oldCert and adds +// a changelist entry to update the root role with this certificate replacement +// shen r.Publish() is called. +func (r *NotaryRepository) RotateRootCert(oldCert *x509.Certificate) error { + oldCertX509PublicKey := trustmanager.CertToKey(oldCert) + if oldCertX509PublicKey == nil { + return fmt.Errorf("invalid format for root key: %s", oldCert.PublicKeyAlgorithm) + } + rootKeyID, err := trustmanager.X509PublicKeyID(oldCertX509PublicKey) + if err != nil { + return err + } + + privKey, _, err := r.CryptoService.GetPrivateKey(rootKeyID) + if err != nil { + return err + } + + // Hard-coded policy: the generated certificate expires in 10 years. + startTime := time.Now() + newCert, err := cryptoservice.GenerateCertificate(privKey, r.gun, startTime, startTime.AddDate(10, 0, 0)) + if err != nil { + return err + } + newCertX509PublicKey := trustmanager.CertToKey(newCert) + if newCertX509PublicKey == nil { + return fmt.Errorf("cannot use regenerated certificate?! format %s", newCert.PublicKeyAlgorithm) + } + + cl, err := changelist.NewFileChangelist(filepath.Join(r.tufRepoPath, "changelist")) + if err != nil { + return err + } + defer cl.Close() + + // Not changelist.ActionCreate/ReplaceKeys, which would drop other certificates. + meta := changelist.TufRootData{ + RoleName: data.CanonicalRootRole, + AddKeys: []data.PublicKey{newCertX509PublicKey}, + RemoveKeys: []string{oldCertX509PublicKey.ID()}, + } + metaJSON, err := json.Marshal(meta) + if err != nil { + return err + } + c := changelist.NewTufChange( + changelist.ActionUpdate, + changelist.ScopeRoot, + changelist.TypeRootRole, + data.CanonicalRootRole, + metaJSON, + ) + return cl.Add(c) + + // Note that we don't need to worry about updating r.CertStore (and synchronizing + // this with the r.Publish() call; after we push the new root.json to the server, + // calls to r.Update() will cause this client to rotate certificates in r.CertStore + // just as other clients do. +} diff --git a/client/client_test.go b/client/client_test.go index 3da76a4a52..343d7c77f9 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/rand" "crypto/sha256" + "crypto/x509" "encoding/hex" regJson "encoding/json" "fmt" @@ -3339,3 +3340,134 @@ func TestListRoles(t *testing.T) { require.NoError(t, err) require.Len(t, rolesWithSigs, len(data.BaseRoles)+2) } + +func logRepoTrustRoot(t *testing.T, prefix string, repo *NotaryRepository) { + logrus.Debugf("==== %s", prefix) + root := repo.tufRepo.Root + logrus.Debugf("Root signatures:") + for _, s := range root.Signatures { + logrus.Debugf("\t%s", s.KeyID) + } + logrus.Debugf("Valid root keys:") + for _, k := range root.Signed.Roles[data.CanonicalRootRole].KeyIDs { + logrus.Debugf("\t%s", k) + } + logrus.Debugf("All trusted certs:") + certs, err := repo.CertStore.GetCertificatesByCN(repo.gun) + require.NoError(t, err) + for _, cert := range certs { + id, err := trustmanager.FingerprintCert(cert) + require.NoError(t, err) + logrus.Debugf("\t%s", id) + } +} + +// ID of the (only) certificate trusted by the root role metadata +func rootRoleCertID(t *testing.T, repo *NotaryRepository) string { + rootKeys := repo.tufRepo.Root.Signed.Roles[data.CanonicalRootRole].KeyIDs + require.Len(t, rootKeys, 1) + return rootKeys[0] +} + +func verifyOnlyTrustedCertificate(t *testing.T, repo *NotaryRepository, certID string) { + certs, err := repo.CertStore.GetCertificatesByCN(repo.gun) + require.NoError(t, err) + require.Len(t, certs, 1) + id, err := trustmanager.FingerprintCert(certs[0]) + require.NoError(t, err) + require.Equal(t, certID, id) +} + +func TestRotateRootCert(t *testing.T) { + ts := fullTestServer(t) + defer ts.Close() + + // Set up author's view of the repo and publish first version. + authorRepo, _ := initializeRepo(t, data.ECDSAKey, "docker.com/notary", ts.URL, false) + defer os.RemoveAll(authorRepo.baseDir) + err := authorRepo.Publish() + require.NoError(t, err) + oldRootCertID := rootRoleCertID(t, authorRepo) + + // Initialize an user, using the original certificate. + userRepo, _ := newRepoToTestRepo(t, authorRepo, true) + defer os.RemoveAll(userRepo.baseDir) + err = userRepo.Update(false) + require.NoError(t, err) + + // Rotate root certificate. + certs, err := authorRepo.ListRootCerts() + require.NoError(t, err) + var cert *x509.Certificate + require.Len(t, certs, 1) + // Just get the only certificate in there. + for _, c := range certs { + cert = c + } + logRepoTrustRoot(t, "original", authorRepo) + err = authorRepo.RotateRootCert(cert) + require.NoError(t, err) + logRepoTrustRoot(t, "post-rotate", authorRepo) + + // Set up a target to verify the repo is actually usable. + publishedTarget := addTarget(t, authorRepo, "current", "../fixtures/intermediate-ca.crt") + + // Publish the rotation + logRepoTrustRoot(t, "pre-publish", authorRepo) + err = authorRepo.Publish() + require.NoError(t, err) + logRepoTrustRoot(t, "post-publish", authorRepo) + newRootCertID := rootRoleCertID(t, authorRepo) + require.NotEqual(t, oldRootCertID, newRootCertID) + + // Verify the user can use the rotated repo, and see the added target. + receivedTarget, err := userRepo.GetTargetByName("current") + require.NoError(t, err) + require.Equal(t, newRootCertID, rootRoleCertID(t, userRepo)) + require.True(t, reflect.DeepEqual(*publishedTarget, receivedTarget.Target), "target does not match") + logRepoTrustRoot(t, "client", userRepo) + + // Verify that clients initialized post-rotation can use the repo, and use + // the new certificate immediately. + freshUserRepo, _ := newRepoToTestRepo(t, authorRepo, true) + defer os.RemoveAll(freshUserRepo.baseDir) + receivedTarget, err = freshUserRepo.GetTargetByName("current") + require.NoError(t, err) + require.Equal(t, newRootCertID, rootRoleCertID(t, freshUserRepo)) + verifyOnlyTrustedCertificate(t, freshUserRepo, newRootCertID) + require.True(t, reflect.DeepEqual(*publishedTarget, receivedTarget.Target), "target does not match") + logRepoTrustRoot(t, "fresh client", freshUserRepo) + + // NotaryRepository.Update's handling of certificate rotation is weird: + // + // On every run, NotaryRepository.bootstrapClient rotates the trusted certificates + // based on CACHED root data. + // Then the client calls Repo.Update, which fetches a new timestmap, + // notices an updated root.json, validates it and stores it into the cache. + // + // So, the locally trusted certificates are rotated only on the SECOND call of + // NotaryRepository.Update after the rotation is pushed to the server. + // + // This would be nice to fix eventually (breaking down the NotaryRepository.Update + // / Repo.Update separation which causes this), but for now, just ensure that the + // second update does result in updated certificates. + + // Verify that the author eventually rotates to the new certificate. + err = authorRepo.Update(false) + require.NoError(t, err) + logRepoTrustRoot(t, "author refresh 1", authorRepo) + require.Equal(t, newRootCertID, rootRoleCertID(t, authorRepo)) + // verifyOnlyTrustedCertificate(t, authorRepo, newRootCertID) + err = authorRepo.Update(false) + require.NoError(t, err) + logRepoTrustRoot(t, "author refresh 2", authorRepo) + require.Equal(t, newRootCertID, rootRoleCertID(t, authorRepo)) + verifyOnlyTrustedCertificate(t, authorRepo, newRootCertID) + + // Verify that the user initialized with the original certificate eventually + // rotates to the new certificate. + err = userRepo.Update(false) + require.NoError(t, err) + logRepoTrustRoot(t, "user refresh 1", userRepo) + require.Equal(t, newRootCertID, rootRoleCertID(t, userRepo)) +} diff --git a/client/helpers.go b/client/helpers.go index 38f1b43cda..1ef829e592 100644 --- a/client/helpers.go +++ b/client/helpers.go @@ -180,7 +180,21 @@ func applyRootRoleChange(repo *tuf.Repo, c changelist.Change) error { if err != nil { return err } - err = repo.ReplaceBaseKeys(d.RoleName, d.Keys...) + err = repo.ReplaceBaseKeys(d.RoleName, d.ReplaceKeys...) + if err != nil { + return err + } + case changelist.ActionUpdate: + d := &changelist.TufRootData{} + err := json.Unmarshal(c.Content(), d) + if err != nil { + return err + } + err = repo.AddBaseKeys(d.RoleName, d.AddKeys...) + if err != nil { + return err + } + err = repo.RemoveBaseKeys(d.RoleName, d.RemoveKeys...) if err != nil { return err }