Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
694 changes: 427 additions & 267 deletions api/types.pb.go

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion api/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,21 @@ message CAConfig {
// ExternalCAs is a list of CAs to which a manager node will make
// certificate signing requests for node certificates.
repeated ExternalCA external_cas = 2 [(gogoproto.customname) = "ExternalCAs"];

// SigningCACert is the desired CA certificate to be used as the root and
// signing CA for the swarm. If not provided, indicates that we are either happy
// with the current configuration, or (together with a bump in the ForceRotate value)
// that we want a certificate and key generated for us.
bytes signing_ca_cert = 3 [(gogoproto.customname) = "SigningCACert"];

// SigningCAKey is the desired private key, matching the signing CA cert, to be used
// to sign certificates for the swarm
bytes signing_ca_key = 4 [(gogoproto.customname) = "SigningCAKey"];

// ForceRotate is a counter that triggers a root CA rotation even if no relevant
// parameters have been in the spec. This will force the manager to generate a new
// certificate and key, if none have been provided.
uint64 force_rotate = 5;
}

// OrchestrationConfig defines cluster-level orchestration settings.
Expand Down Expand Up @@ -771,6 +786,10 @@ message RootCA {
// RootRotation contains the new root cert and key we want to rotate to - if this is nil, we are not in the
// middle of a root rotation
RootRotation root_rotation = 5;

// LastForcedRotation matches the Cluster Spec's CAConfig's ForceRotation counter.
// It indicates when the current CA cert and key were generated (or updated).
uint64 last_forced_rotation = 6;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't all rotations forced? why not just last_rotation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't particularly feel strongly - the name is to make it clearer that it corresponds with the ForceRotation value in the config.

}


Expand Down Expand Up @@ -922,5 +941,5 @@ message RootRotation {
bytes ca_cert = 1 [(gogoproto.customname) = "CACert"];
bytes ca_key = 2 [(gogoproto.customname) = "CAKey"];
// cross-signed CA cert is the CACert that has been cross-signed by the previous root
bytes cross_signed_ca_cert = 3 [(gogoproto.customname) = "CrossSignedCACert"];;
bytes cross_signed_ca_cert = 3 [(gogoproto.customname) = "CrossSignedCACert"];
}
13 changes: 13 additions & 0 deletions ca/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ func NewExternalCA(rootCA *RootCA, tlsConfig *tls.Config, urls ...string) *Exter
}
}

// Copy returns a copy of the external CA that can be updated independently
func (eca *ExternalCA) Copy() *ExternalCA {
eca.mu.Lock()
defer eca.mu.Unlock()

return &ExternalCA{
ExternalRequestTimeout: eca.ExternalRequestTimeout,
rootCA: eca.rootCA,
urls: eca.urls,
client: eca.client,
}
}

// UpdateTLSConfig updates the HTTP Client for this ExternalCA by creating
// a new client which uses the given tlsConfig.
func (eca *ExternalCA) UpdateTLSConfig(tlsConfig *tls.Config) {
Expand Down
29 changes: 29 additions & 0 deletions ca/external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,32 @@ func TestExternalCASignRequestTimesOut(t *testing.T) {
require.FailNow(t, "call to external CA signing should have timed out after 1 second - it's been 3")
}
}

func TestExternalCACopy(t *testing.T) {
t.Parallel()

if !testutils.External {
return // this is only tested using the external CA
}

tc := testutils.NewTestCA(t)
defer tc.Stop()

csr, _, err := ca.GenerateNewCSR()
require.NoError(t, err)
signReq := ca.PrepareCSR(csr, "cn", ca.ManagerRole, tc.Organization)

secConfig, err := tc.NewNodeConfig(ca.ManagerRole)
require.NoError(t, err)
externalCA1 := secConfig.ExternalCA()
externalCA2 := externalCA1.Copy()
externalCA2.UpdateURLs(tc.ExternalSigningServer.URL)

// externalCA1 can't sign, but externalCA2, which has been updated with URLS, can
_, err = externalCA1.Sign(context.Background(), signReq)
require.Equal(t, ca.ErrNoExternalCAURLs, err)

cert, err := externalCA2.Sign(context.Background(), signReq)
require.NoError(t, err)
require.NotNil(t, cert)
}
12 changes: 12 additions & 0 deletions ca/testutils/cautils.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package testutils

import (
"crypto"
cryptorand "crypto/rand"
"crypto/tls"
"crypto/x509"
Expand Down Expand Up @@ -418,3 +419,14 @@ func ReDateCert(t *testing.T, cert, signerCert, signerKey []byte, notBefore, not
Bytes: derBytes,
})
}

// CreateCertFromSigner creates a Certificate authority for a new Swarm Cluster given an existing key only.
func CreateCertFromSigner(rootCN string, priv crypto.Signer) ([]byte, error) {
req := cfcsr.CertificateRequest{
CN: rootCN,
KeyRequest: &cfcsr.BasicKeyRequest{A: ca.RootKeyAlgo, S: ca.RootKeySize},
CA: &cfcsr.CAConfig{Expiry: ca.RootCAExpiration},
}
cert, _, err := initca.NewFromSigner(&req, priv)
return cert, err
}
265 changes: 265 additions & 0 deletions manager/controlapi/ca_rotation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package controlapi

import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/url"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"

"github.com/cloudflare/cfssl/helpers"
"github.com/docker/swarmkit/api"
"github.com/docker/swarmkit/ca"
"github.com/docker/swarmkit/log"
)

var minRootExpiration = 1 * helpers.OneYear

// determines whether an api.RootCA, api.RootRotation, or api.CAConfig has a signing key (local signer)
func hasSigningKey(a interface{}) bool {
switch b := a.(type) {
case api.RootCA:
return len(b.CAKey) > 0
case *api.RootRotation:
return b != nil && len(b.CAKey) > 0
case api.CAConfig:
return len(b.SigningCACert) > 0 && len(b.SigningCAKey) > 0
default:
panic("needsExternalCAs should be called something of type api.RootCA, *api.RootRotation, or api.CAConfig")
}
}

// Creates a cross-signed intermediate and new api.RootRotation object.
// This function assumes that the root cert and key and the external CAs have already been validated.
func newRootRotationObject(ctx context.Context, securityConfig *ca.SecurityConfig, cluster *api.Cluster, newRootCA ca.RootCA, version uint64) (*api.RootCA, error) {
var (
rootCert, rootKey, crossSignedCert []byte
newRootHasSigner bool
err error
)

rootCert = newRootCA.Certs
if s, err := newRootCA.Signer(); err == nil {
rootCert, rootKey = s.Cert, s.Key
newRootHasSigner = true
}

// we have to sign with the original signer, not whatever is in the SecurityConfig's RootCA (which may have an intermediate signer, if
// a root rotation is already in progress)
switch {
case hasSigningKey(cluster.RootCA):
var oldRootCA ca.RootCA
oldRootCA, err = ca.NewRootCA(cluster.RootCA.CACert, cluster.RootCA.CACert, cluster.RootCA.CAKey, ca.DefaultNodeCertExpiration, nil)
if err == nil {
crossSignedCert, err = oldRootCA.CrossSignCACertificate(rootCert)
}
case !newRootHasSigner: // the original CA and the new CA both require external CAs
return nil, grpc.Errorf(codes.InvalidArgument, "rotating from one external CA to a different external CA is not supported")
default:
// We need the same credentials but to connect to the original URLs (in case we are in the middle of a root rotation already)
externalCA := securityConfig.ExternalCA().Copy()
var urls []string
for _, c := range cluster.Spec.CAConfig.ExternalCAs {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere in our code failing this if should do a Debugf. Are we expecting this to be a normal occurrence?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what you mean? Do you mean changing the desired cert and signer while another rotation has yet to finish?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I just meant: there is a loop here ignoring things that are invalid; do we log that as a warning anywhere in our code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh ok, yes I can add that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added the logging.

if c.Protocol == api.ExternalCA_CAProtocolCFSSL && bytes.Equal(c.CACert, cluster.RootCA.CACert) {
urls = append(urls, c.URL)
}
}
if len(urls) == 0 {
return nil, grpc.Errorf(codes.InvalidArgument,
"must provide an external CA for the current external root CA to generate a cross-signed certificate")
}
externalCA.UpdateURLs(urls...)
crossSignedCert, err = externalCA.CrossSignRootCA(ctx, newRootCA)
}

if err != nil {
log.G(ctx).WithError(err).Error("unable to generate a cross-signed certificate for root rotation")
return nil, grpc.Errorf(codes.Internal, "unable to generate a cross-signed certificate for root rotation")
}

copied := cluster.RootCA.Copy()
copied.RootRotation = &api.RootRotation{
CACert: rootCert,
CAKey: rootKey,
CrossSignedCACert: crossSignedCert,
}
copied.LastForcedRotation = version
return copied, nil
}

// Checks that a CA URL is connectable using the credentials we have and that its server certificate is signed by the
// root CA that we expect. This uses a TCP dialer rather than an HTTP client; because we have custom TLS configuration,
// if we wanted to use an HTTP client we'd have to create a new transport for every connection. The docs specify that
// Transports cache connections for future re-use, which could cause many open connections.
func validateExternalCAURL(dialer *net.Dialer, tlsOpts *tls.Config, caURL string) error {
parsed, err := url.Parse(caURL)
if err != nil {
return err
}
if parsed.Scheme != "https" {
return errors.New("invalid HTTP scheme")
}
host, port, err := net.SplitHostPort(parsed.Host)
if err != nil {
// It either has no port or is otherwise invalid (e.g. too many colons). If it's otherwise invalid the dialer
// will error later, so just assume it's no port and set the port to the default HTTPS port.
host = parsed.Host
port = "443"
}

conn, err := tls.DialWithDialer(dialer, "tcp", net.JoinHostPort(host, port), tlsOpts)
if conn != nil {
conn.Close()
}
return err
}

// Iterates over all the external CAs, and validates that there is at least 1 reachable, valid external CA for the
// given CA certificate. Returns true if there is, false otherwise.
func hasAtLeastOneExternalCA(ctx context.Context, externalCAs []*api.ExternalCA, securityConfig *ca.SecurityConfig, wantedCert []byte) bool {
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(wantedCert)
dialer := net.Dialer{Timeout: 5 * time.Second}
opts := tls.Config{
RootCAs: pool,
Certificates: securityConfig.ClientTLSCreds.Config().Certificates,
}
for i, ca := range externalCAs {
if ca.Protocol == api.ExternalCA_CAProtocolCFSSL && bytes.Equal(wantedCert, ca.CACert) {
err := validateExternalCAURL(&dialer, &opts, ca.URL)
if err == nil {
return true
}
log.G(ctx).WithError(err).Warnf("external CA # %d is unreachable or invalid", i+1)
}
}
return false
}

// All new external CA definitions must include the CA cert associated with the external CA.
// If the current root CA requires an external CA, then at least one, reachable valid external CA must be provided that
// corresponds with the current RootCA's certificate.
//
// Similarly for the desired CA certificate, if one is specified. Similarly for the current outstanding root CA rotation,
// if one is specified and will not be replaced with the desired CA.
func validateHasRequiredExternalCAs(ctx context.Context, securityConfig *ca.SecurityConfig, cluster *api.Cluster) error {
config := cluster.Spec.CAConfig
for _, ca := range config.ExternalCAs {
if len(ca.CACert) == 0 {
return grpc.Errorf(codes.InvalidArgument, "must specify CA certificate for each external CA")
}
}

if !hasSigningKey(cluster.RootCA) && !hasAtLeastOneExternalCA(ctx, config.ExternalCAs, securityConfig, cluster.RootCA.CACert) {
return grpc.Errorf(codes.InvalidArgument, "there must be at least one valid, reachable external CA corresponding to the current CA certificate")
}

if len(config.SigningCACert) > 0 { // a signing cert is specified
if !hasSigningKey(config) && !hasAtLeastOneExternalCA(ctx, config.ExternalCAs, securityConfig, config.SigningCACert) {
return grpc.Errorf(codes.InvalidArgument, "there must be at least one valid, reachable external CA corresponding to the desired CA certificate")
}
} else if config.ForceRotate == cluster.RootCA.LastForcedRotation && cluster.RootCA.RootRotation != nil {
// no cert is specified but force rotation hasn't changed (so we are happy with the current configuration) and there's an outstanding root rotation
if !hasSigningKey(cluster.RootCA.RootRotation) && !hasAtLeastOneExternalCA(ctx, config.ExternalCAs, securityConfig, cluster.RootCA.RootRotation.CACert) {
return grpc.Errorf(codes.InvalidArgument, "there must be at least one valid, reachable external CA corresponding to the next CA certificate")
}
}

return nil
}

// validateAndUpdateCA validates a cluster's desired CA configuration spec, and returns a RootCA value on success representing
// current RootCA as it should be. Validation logic and return values are as follows:
// 1. Validates that the contents are complete - e.g. a signing key is not provided without a signing cert, and that external
// CAs are not removed if they are needed. Otherwise, returns an error.
// 2. If no desired signing cert or key are provided, then either:
// - we are happy with the current CA configuration (force rotation value has not changed), and we return the current RootCA
// object as is
// - we want to generate a new internal CA cert and key (force rotation value has changed), and we return the updated RootCA
// object
// 3. Signing cert and key have been provided: validate that these match (the cert and key match). Otherwise, return an error.
// 4. Return the updated RootCA object according to the following criteria:
// - If the desired cert is the same as the current CA cert then abort any outstanding rotations. The current signing key
// is replaced with the desired signing key (this could lets us switch between external->internal or internal->external
// without an actual CA rotation, which is not needed because any leaf cert issued with one CA cert can be validated using
// the second CA certificate).
// - If the desired cert is the same as the current to-be-rotated-to CA cert then a new root rotation is not needed. The
// current to-be-rotated-to signing key is replaced with the desired signing key (this could lets us switch between
// external->internal or internal->external without an actual CA rotation, which is not needed because any leaf cert
// issued with one CA cert can be validated using the second CA certificate).
// - Otherwise, start a new root rotation using the desired signing cert and desired signing key as the root rotation
// signing cert and key. If a root rotation is already in progress, just replace it and start over.
func validateCAConfig(ctx context.Context, securityConfig *ca.SecurityConfig, cluster *api.Cluster) (*api.RootCA, error) {
newConfig := cluster.Spec.CAConfig

if len(newConfig.SigningCAKey) > 0 && len(newConfig.SigningCACert) == 0 {
return nil, grpc.Errorf(codes.InvalidArgument, "if a signing CA key is provided, the signing CA cert must also be provided")
}

if err := validateHasRequiredExternalCAs(ctx, securityConfig, cluster); err != nil {
return nil, err
}

// if the desired CA cert and key are not set, then we are happy with the current root CA configuration, unless
// the ForceRotate version has changed
if len(newConfig.SigningCACert) == 0 {
if cluster.RootCA.LastForcedRotation != newConfig.ForceRotate {
newRootCA, err := ca.CreateRootCA(ca.DefaultRootCN)
if err != nil {
return nil, grpc.Errorf(codes.Internal, err.Error())
}
return newRootRotationObject(ctx, securityConfig, cluster, newRootCA, newConfig.ForceRotate)
}
return &cluster.RootCA, nil // no change, return as is
}

// A desired cert and maybe key were provided - we need to make sure the cert and key (if provided) match.
var signingCert []byte
if hasSigningKey(newConfig) {
signingCert = newConfig.SigningCACert
}
newRootCA, err := ca.NewRootCA(newConfig.SigningCACert, signingCert, newConfig.SigningCAKey, ca.DefaultNodeCertExpiration, nil)
if err != nil {
return nil, grpc.Errorf(codes.InvalidArgument, err.Error())
}

if len(newRootCA.Pool.Subjects()) != 1 {
return nil, grpc.Errorf(codes.InvalidArgument, "the desired CA certificate cannot contain multiple certificates")
}

parsedCert, err := helpers.ParseCertificatePEM(newConfig.SigningCACert)
if err != nil {
return nil, grpc.Errorf(codes.InvalidArgument, "could not parse the desired CA certificate")
}

// The new certificate's expiry must be at least one year away
if parsedCert.NotAfter.Before(time.Now().Add(minRootExpiration)) {
return nil, grpc.Errorf(codes.InvalidArgument, "CA certificate expires too soon")
}

// check if we can abort any existing root rotations
if bytes.Equal(cluster.RootCA.CACert, cluster.Spec.CAConfig.SigningCACert) {
copied := cluster.RootCA.Copy()
copied.CAKey = newConfig.SigningCAKey
copied.RootRotation = nil
copied.LastForcedRotation = newConfig.ForceRotate
return copied, nil
}

// check if this is the same desired cert as an existing root rotation
if r := cluster.RootCA.RootRotation; r != nil && bytes.Equal(r.CACert, cluster.Spec.CAConfig.SigningCACert) {
copied := cluster.RootCA.Copy()
copied.RootRotation.CAKey = newConfig.SigningCAKey
copied.LastForcedRotation = newConfig.ForceRotate
return copied, nil
}

// ok, everything's different; we have to begin a new root rotation which means generating a new cross-signed cert
return newRootRotationObject(ctx, securityConfig, cluster, newRootCA, newConfig.ForceRotate)
}
Loading