diff --git a/ca/certificates.go b/ca/certificates.go index 348f16ad83..7a7b9abc73 100644 --- a/ca/certificates.go +++ b/ca/certificates.go @@ -9,6 +9,7 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" + "encoding/asn1" "encoding/pem" "fmt" "io" @@ -72,6 +73,9 @@ const ( MinNodeCertExpiration = 1 * time.Hour ) +// BasicConstraintsOID is the ASN1 Object ID indicating a basic constraints extension +var BasicConstraintsOID = asn1.ObjectIdentifier{2, 5, 29, 19} + // A recoverableErr is a non-fatal error encountered signing a certificate, // which means that the certificate issuance may be retried at a later time. type recoverableErr struct { @@ -305,6 +309,42 @@ func (rca *RootCA) ParseValidateAndSignCSR(csrBytes []byte, cn, ou, org string) return cert, nil } +// CrossSignCACertificate takes a CA root certificate and generates an intermediate CA from it signed with the current root signer +func (rca *RootCA) CrossSignCACertificate(otherCAPEM []byte) ([]byte, error) { + if !rca.CanSign() { + return nil, ErrNoValidSigner + } + + // create a new cert with exactly the same parameters, including the public key and exact NotBefore and NotAfter + rootCert, err := helpers.ParseCertificatePEM(rca.Cert) + if err != nil { + return nil, errors.Wrap(err, "could not parse old CA certificate") + } + rootSigner, err := helpers.ParsePrivateKeyPEM(rca.Signer.Key) + if err != nil { + return nil, errors.Wrap(err, "could not parse old CA key") + } + + newCert, err := helpers.ParseCertificatePEM(otherCAPEM) + if err != nil { + return nil, errors.New("could not parse new CA certificate") + } + + if !newCert.IsCA { + return nil, errors.New("certificate not a CA") + } + + derBytes, err := x509.CreateCertificate(cryptorand.Reader, newCert, rootCert, newCert.PublicKey, rootSigner) + if err != nil { + return nil, errors.Wrap(err, "could not cross-sign new CA certificate using old CA material") + } + + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: derBytes, + }), nil +} + // NewRootCA creates a new RootCA object from unparsed PEM cert bundle and key byte // slices. key may be nil, and in this case NewRootCA will return a RootCA // without a signer. diff --git a/ca/certificates_test.go b/ca/certificates_test.go index 94c2102b2f..0828cd240c 100644 --- a/ca/certificates_test.go +++ b/ca/certificates_test.go @@ -888,3 +888,66 @@ func TestValidateCertificateChain(t *testing.T) { require.NoError(t, err) } } + +// Tests cross-signing using a certificate +func TestRootCACrossSignCACertificate(t *testing.T) { + t.Parallel() + + cert1, key1, err := testutils.CreateRootCertAndKey("rootCN") + require.NoError(t, err) + + rootCA1, err := ca.NewRootCA(cert1, key1, ca.DefaultNodeCertExpiration) + require.NoError(t, err) + + cert2, key2, err := testutils.CreateRootCertAndKey("rootCN2") + require.NoError(t, err) + + rootCA2, err := ca.NewRootCA(cert2, key2, ca.DefaultNodeCertExpiration) + require.NoError(t, err) + + tempdir, err := ioutil.TempDir("", "cross-sign-cert") + require.NoError(t, err) + defer os.RemoveAll(tempdir) + paths := ca.NewConfigPaths(tempdir) + krw := ca.NewKeyReadWriter(paths.Node, nil, nil) + + _, err = rootCA2.IssueAndSaveNewCertificates(krw, "cn", "ou", "org") + require.NoError(t, err) + certBytes, _, err := krw.Read() + require.NoError(t, err) + leafCert, err := helpers.ParseCertificatePEM(certBytes) + require.NoError(t, err) + + // cross-signing a non-CA fails + _, err = rootCA1.CrossSignCACertificate(certBytes) + require.Error(t, err) + + // cross-signing some non-cert PEM bytes fail + _, err = rootCA1.CrossSignCACertificate(key1) + require.Error(t, err) + + intermediate, err := rootCA1.CrossSignCACertificate(cert2) + require.NoError(t, err) + parsedIntermediate, err := helpers.ParseCertificatePEM(intermediate) + require.NoError(t, err) + parsedRoot2, err := helpers.ParseCertificatePEM(cert2) + require.NoError(t, err) + require.Equal(t, parsedRoot2.RawSubject, parsedIntermediate.RawSubject) + require.Equal(t, parsedRoot2.RawSubjectPublicKeyInfo, parsedIntermediate.RawSubjectPublicKeyInfo) + require.True(t, parsedIntermediate.IsCA) + + intermediatePool := x509.NewCertPool() + intermediatePool.AddCert(parsedIntermediate) + + // we can validate a chain from the leaf to the first root through the intermediate, + // or from the leaf cert to the second root with or without the intermediate + _, err = leafCert.Verify(x509.VerifyOptions{Roots: rootCA1.Pool}) + require.Error(t, err) + _, err = leafCert.Verify(x509.VerifyOptions{Roots: rootCA1.Pool, Intermediates: intermediatePool}) + require.NoError(t, err) + + _, err = leafCert.Verify(x509.VerifyOptions{Roots: rootCA2.Pool}) + require.NoError(t, err) + _, err = leafCert.Verify(x509.VerifyOptions{Roots: rootCA2.Pool, Intermediates: intermediatePool}) + require.NoError(t, err) +} diff --git a/ca/external.go b/ca/external.go index 492d55c300..f53078f8f4 100644 --- a/ca/external.go +++ b/ca/external.go @@ -2,14 +2,21 @@ package ca import ( "bytes" + cryptorand "crypto/rand" "crypto/tls" + "crypto/x509" + "encoding/hex" "encoding/json" + "encoding/pem" "io/ioutil" "net/http" "sync" "github.com/Sirupsen/logrus" "github.com/cloudflare/cfssl/api" + "github.com/cloudflare/cfssl/config" + "github.com/cloudflare/cfssl/csr" + "github.com/cloudflare/cfssl/helpers" "github.com/cloudflare/cfssl/signer" "github.com/pkg/errors" "golang.org/x/net/context" @@ -97,6 +104,62 @@ func (eca *ExternalCA) Sign(ctx context.Context, req signer.SignRequest) (cert [ return nil, err } +// CrossSignRootCA takes a RootCA object, generates a CA CSR, sends a signing request with the CA CSR to the external +// CFSSL API server in order to obtain a cross-signed root +func (eca *ExternalCA) CrossSignRootCA(ctx context.Context, rca RootCA) ([]byte, error) { + if !rca.CanSign() { + return nil, errors.Wrap(ErrNoValidSigner, "cannot generate CSR for a cross-signed root") + } + rootCert, err := helpers.ParseCertificatePEM(rca.Cert) + if err != nil { + return nil, errors.Wrap(err, "could not parse CA certificate") + } + rootSigner, err := helpers.ParsePrivateKeyPEM(rca.Signer.Key) + if err != nil { + return nil, errors.Wrap(err, "could not parse old CA key") + } + // ExtractCertificateRequest generates a new key request, and we want to continue to use the old + // key. However, ExtractCertificateRequest will also convert the pkix.Name to csr.Name, which we + // need in order to generate a signing request + cfCSRObj := csr.ExtractCertificateRequest(rootCert) + + der, err := x509.CreateCertificateRequest(cryptorand.Reader, &x509.CertificateRequest{ + RawSubjectPublicKeyInfo: rootCert.RawSubjectPublicKeyInfo, + RawSubject: rootCert.RawSubject, + PublicKeyAlgorithm: rootCert.PublicKeyAlgorithm, + Subject: rootCert.Subject, + Extensions: rootCert.Extensions, + DNSNames: rootCert.DNSNames, + EmailAddresses: rootCert.EmailAddresses, + IPAddresses: rootCert.IPAddresses, + }, rootSigner) + if err != nil { + return nil, err + } + req := signer.SignRequest{ + Request: string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: der, + })), + Subject: &signer.Subject{ + CN: rootCert.Subject.CommonName, + Names: cfCSRObj.Names, + }, + } + // cfssl actually ignores non subject alt name extensions in the CSR, so we have to add the CA extension in the signing + // request as well + for _, ext := range rootCert.Extensions { + if ext.Id.Equal(BasicConstraintsOID) { + req.Extensions = append(req.Extensions, signer.Extension{ + ID: config.OID(ext.Id), + Critical: ext.Critical, + Value: hex.EncodeToString(ext.Value), + }) + } + } + return eca.Sign(ctx, req) +} + func makeExternalSignRequest(ctx context.Context, client *http.Client, url string, csrJSON []byte) (cert []byte, err error) { resp, err := ctxhttp.Post(ctx, client, url, "application/json", bytes.NewReader(csrJSON)) if err != nil { diff --git a/ca/external_test.go b/ca/external_test.go new file mode 100644 index 0000000000..b064746f3c --- /dev/null +++ b/ca/external_test.go @@ -0,0 +1,79 @@ +package ca_test + +import ( + "context" + "crypto/x509" + "testing" + + "github.com/cloudflare/cfssl/helpers" + "github.com/docker/swarmkit/ca" + "github.com/docker/swarmkit/ca/testutils" + "github.com/stretchr/testify/require" +) + +// Tests ExternalCA.CrossSignRootCA can produce an intermediate that can be used to +// validate a leaf certificate +func TestExternalCACrossSign(t *testing.T) { + t.Parallel() + + if !testutils.External { + return // this is only tested using the external CA + } + + tc := testutils.NewTestCA(t) + defer tc.Stop() + paths := ca.NewConfigPaths(tc.TempDir) + + secConfig, err := tc.RootCA.CreateSecurityConfig(context.Background(), + ca.NewKeyReadWriter(paths.Node, nil, nil), ca.CertificateRequestConfig{}) + require.NoError(t, err) + externalCA := secConfig.ExternalCA() + externalCA.UpdateURLs(tc.ExternalSigningServer.URL) + + cert2, key2, err := testutils.CreateRootCertAndKey("rootCN2") + require.NoError(t, err) + + rootCA2, err := ca.NewRootCA(cert2, key2, ca.DefaultNodeCertExpiration) + require.NoError(t, err) + + krw := ca.NewKeyReadWriter(paths.Node, nil, nil) + + _, err = rootCA2.IssueAndSaveNewCertificates(krw, "cn", "ou", "org") + require.NoError(t, err) + certBytes, _, err := krw.Read() + require.NoError(t, err) + leafCert, err := helpers.ParseCertificatePEM(certBytes) + require.NoError(t, err) + + // we have not enabled CA signing on the external server + _, err = externalCA.CrossSignRootCA(context.Background(), rootCA2) + require.Error(t, err) + + require.NoError(t, tc.ExternalSigningServer.EnableCASigning()) + + intermediate, err := externalCA.CrossSignRootCA(context.Background(), rootCA2) + require.NoError(t, err) + + parsedIntermediate, err := helpers.ParseCertificatePEM(intermediate) + require.NoError(t, err) + parsedRoot2, err := helpers.ParseCertificatePEM(cert2) + require.NoError(t, err) + require.Equal(t, parsedRoot2.RawSubject, parsedIntermediate.RawSubject) + require.Equal(t, parsedRoot2.RawSubjectPublicKeyInfo, parsedIntermediate.RawSubjectPublicKeyInfo) + require.True(t, parsedIntermediate.IsCA) + + intermediatePool := x509.NewCertPool() + intermediatePool.AddCert(parsedIntermediate) + + // we can validate a chain from the leaf to the first root through the intermediate, + // or from the leaf cert to the second root with or without the intermediate + _, err = leafCert.Verify(x509.VerifyOptions{Roots: tc.RootCA.Pool}) + require.Error(t, err) + _, err = leafCert.Verify(x509.VerifyOptions{Roots: tc.RootCA.Pool, Intermediates: intermediatePool}) + require.NoError(t, err) + + _, err = leafCert.Verify(x509.VerifyOptions{Roots: rootCA2.Pool}) + require.NoError(t, err) + _, err = leafCert.Verify(x509.VerifyOptions{Roots: rootCA2.Pool, Intermediates: intermediatePool}) + require.NoError(t, err) +} diff --git a/ca/testutils/externalutils.go b/ca/testutils/externalutils.go index 8110e3dd60..d8f57a4530 100644 --- a/ca/testutils/externalutils.go +++ b/ca/testutils/externalutils.go @@ -2,6 +2,7 @@ package testutils import ( "crypto/tls" + "encoding/asn1" "encoding/json" "fmt" "net" @@ -9,11 +10,17 @@ import ( "net/url" "path/filepath" "strconv" + "sync" "sync/atomic" + "encoding/hex" + "github.com/cloudflare/cfssl/api" + "github.com/cloudflare/cfssl/csr" cfsslerrors "github.com/cloudflare/cfssl/errors" + "github.com/cloudflare/cfssl/helpers" "github.com/cloudflare/cfssl/signer" + "github.com/cloudflare/cfssl/signer/local" "github.com/docker/swarmkit/ca" "github.com/pkg/errors" ) @@ -70,6 +77,7 @@ func NewExternalSigningServer(rootCA ca.RootCA, basedir string) (*ExternalSignin flaky: &ess.flaky, } mux.Handle(signURL.Path, handler) + ess.handler = handler server := &http.Server{ Handler: mux, @@ -87,6 +95,7 @@ type ExternalSigningServer struct { NumIssued uint64 URL string flaky uint32 + handler *signHandler } // Stop stops this signing server by closing the underlying TCP/TLS listener. @@ -104,10 +113,52 @@ func (ess *ExternalSigningServer) Deflake() { atomic.StoreUint32(&ess.flaky, 0) } +// EnableCASigning updates the root CA signer to be able to sign CAs +func (ess *ExternalSigningServer) EnableCASigning() error { + ess.handler.mu.Lock() + defer ess.handler.mu.Unlock() + + rca := ess.handler.rootCA + + rootCert, err := helpers.ParseCertificatePEM(rca.Cert) + if err != nil { + return errors.Wrap(err, "could not parse old CA certificate") + } + rootSigner, err := helpers.ParsePrivateKeyPEM(rca.Signer.Key) + if err != nil { + return errors.Wrap(err, "could not parse old CA key") + } + + // without the whitelist, we can't accept signing requests with CA extensions + policy := ca.SigningPolicy(ca.DefaultNodeCertExpiration) + if policy.Default.ExtensionWhitelist == nil { + policy.Default.ExtensionWhitelist = make(map[string]bool) + } + policy.Default.ExtensionWhitelist[ca.BasicConstraintsOID.String()] = true + policy.Default.Usage = append(policy.Default.Usage, "cert sign") + + caSigner, err := local.NewSigner(rootSigner, rootCert, signer.DefaultSigAlgo(rootSigner), policy) + if err != nil { + return errors.Wrap(err, "could not create CA signer") + } + + ess.handler.caSigner = caSigner + return nil +} + +// DisableCASigning prevents the server from being able to sign CA certificates +func (ess *ExternalSigningServer) DisableCASigning() { + ess.handler.mu.Lock() + defer ess.handler.mu.Unlock() + ess.handler.caSigner = nil +} + type signHandler struct { + mu sync.Mutex numIssued *uint64 rootCA ca.RootCA flaky *uint32 + caSigner signer.Signer } func (h *signHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -160,16 +211,52 @@ func (h *signHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // The client's Org should match the Org in the sign request subject. - if len(reqSub.Name().Organization) == 0 || reqSub.Name().Organization[0] != clientOrg { - cfsslErr := cfsslerrors.New(cfsslerrors.CSRError, cfsslerrors.BadRequest) - errResponse := api.NewErrorResponse("sign request subject org does not match client certificate org", cfsslErr.ErrorCode) - json.NewEncoder(w).Encode(errResponse) - return + var ( + isCA bool + certPEM []byte + err error + ) + // is this a CA CSR? If so, do we support CA signing? + // based on cfssl/signer/signer.go's ParseCertificateRequest to tell from the extensions if it's a CA + for _, ext := range signReq.Extensions { + // Check the CSR for the X.509 BasicConstraints (RFC 5280, 4.2.1.9) + // extension and append to template if necessary + if asn1.ObjectIdentifier(ext.ID).Equal(ca.BasicConstraintsOID) { + rawVal, err := hex.DecodeString(ext.Value) + if err != nil { + continue + } + var constraints csr.BasicConstraints + rest, err := asn1.Unmarshal(rawVal, &constraints) + if err != nil || len(rest) != 0 { + // technically failure conditions, but these will actually be caught when signing the request + continue + } + + if isCA = constraints.IsCA; isCA { + break + } + } } - // Finally, sign the requested certificate. - certPEM, err := h.rootCA.Signer.Sign(signReq) + h.mu.Lock() + if isCA && h.caSigner != nil { + // Sign the requested CA certificate + certPEM, err = h.caSigner.Sign(signReq) + h.mu.Unlock() + } else { + h.mu.Unlock() + // The client's Org should match the Org in the sign request subject. + if len(reqSub.Name().Organization) == 0 || reqSub.Name().Organization[0] != clientOrg { + cfsslErr := cfsslerrors.New(cfsslerrors.CSRError, cfsslerrors.BadRequest) + errResponse := api.NewErrorResponse("sign request subject org does not match client certificate org", cfsslErr.ErrorCode) + json.NewEncoder(w).Encode(errResponse) + return + } + + // Sign the requested leaf certificate. + certPEM, err = h.rootCA.Signer.Sign(signReq) + } if err != nil { cfsslErr := cfsslerrors.New(cfsslerrors.APIClientError, cfsslerrors.ServerRequestFailed) errResponse := api.NewErrorResponse(fmt.Sprintf("unable to sign requested certificate: %s", err), cfsslErr.ErrorCode)