From 7349c94c23cfa179b059499a8d76e302ae714440 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Mar 2018 10:48:38 -0700 Subject: [PATCH] connect: create connect package for helpers --- connect/ca.go | 48 +++++++++ connect/connect.go | 3 + connect/testing.go | 230 ++++++++++++++++++++++++++++++++++++++++ connect/testing_test.go | 109 +++++++++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 connect/ca.go create mode 100644 connect/connect.go create mode 100644 connect/testing.go create mode 100644 connect/testing_test.go diff --git a/connect/ca.go b/connect/ca.go new file mode 100644 index 0000000000..e9ada49531 --- /dev/null +++ b/connect/ca.go @@ -0,0 +1,48 @@ +package connect + +import ( + "crypto" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" +) + +// ParseCert parses the x509 certificate from a PEM-encoded value. +func ParseCert(pemValue string) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(pemValue)) + if block == nil { + return nil, fmt.Errorf("no PEM-encoded data found") + } + + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("first PEM-block should be CERTIFICATE type") + } + + return x509.ParseCertificate(block.Bytes) +} + +// ParseSigner parses a crypto.Signer from a PEM-encoded key. The private key +// is expected to be the first block in the PEM value. +func ParseSigner(pemValue string) (crypto.Signer, error) { + block, _ := pem.Decode([]byte(pemValue)) + if block == nil { + return nil, fmt.Errorf("no PEM-encoded data found") + } + + switch block.Type { + case "EC PRIVATE KEY": + return x509.ParseECPrivateKey(block.Bytes) + + default: + return nil, fmt.Errorf("unknown PEM block type for signing key: %s", block.Type) + } +} + +// SerialNumber generates a serial number suitable for a certificate. +// +// This function is taken directly from the Vault implementation. +func SerialNumber() (*big.Int, error) { + return rand.Int(rand.Reader, (&big.Int{}).Exp(big.NewInt(2), big.NewInt(159), nil)) +} diff --git a/connect/connect.go b/connect/connect.go new file mode 100644 index 0000000000..b2ad85f71c --- /dev/null +++ b/connect/connect.go @@ -0,0 +1,3 @@ +// Package connect contains utilities and helpers for working with the +// Connect feature of Consul. +package connect diff --git a/connect/testing.go b/connect/testing.go new file mode 100644 index 0000000000..78008270a1 --- /dev/null +++ b/connect/testing.go @@ -0,0 +1,230 @@ +package connect + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "net/url" + "sync/atomic" + "time" + + "github.com/hashicorp/consul/agent/structs" + "github.com/mitchellh/go-testing-interface" +) + +// testClusterID is the Consul cluster ID for testing. +// +// NOTE(mitchellh): This might have to change some other constant for +// real testing once we integrate the Cluster ID into the core. For now it +// is unchecked. +const testClusterID = "11111111-2222-3333-4444-555555555555" + +// testCACounter is just an atomically incremented counter for creating +// unique names for the CA certs. +var testCACounter uint64 = 0 + +// TestCA creates a test CA certificate and signing key and returns it +// in the CARoot structure format. The CARoot returned will NOT have an ID +// set. +// +// If xc is non-nil, then the returned certificate will have a signing cert +// that is cross-signed with the previous cert, and this will be set as +// SigningCert. +func TestCA(t testing.T, xc *structs.CARoot) *structs.CARoot { + var result structs.CARoot + result.Name = fmt.Sprintf("Test CA %d", atomic.AddUint64(&testCACounter, 1)) + + // Create the private key we'll use for this CA cert. + signer := testPrivateKey(t, &result) + + // The serial number for the cert + sn, err := SerialNumber() + if err != nil { + t.Fatalf("error generating serial number: %s", err) + } + + // The URI (SPIFFE compatible) for the cert + uri, err := url.Parse(fmt.Sprintf("spiffe://%s.consul", testClusterID)) + if err != nil { + t.Fatalf("error parsing CA URI: %s", err) + } + + // Create the CA cert + template := x509.Certificate{ + SerialNumber: sn, + Subject: pkix.Name{CommonName: result.Name}, + URIs: []*url.URL{uri}, + PermittedDNSDomainsCritical: true, + PermittedDNSDomains: []string{uri.Hostname()}, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + NotBefore: time.Now(), + AuthorityKeyId: testKeyID(t, signer.Public()), + SubjectKeyId: testKeyID(t, signer.Public()), + } + + bs, err := x509.CreateCertificate( + rand.Reader, &template, &template, signer.Public(), signer) + if err != nil { + t.Fatalf("error generating CA certificate: %s", err) + } + + var buf bytes.Buffer + err = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: bs}) + if err != nil { + t.Fatalf("error encoding private key: %s", err) + } + result.RootCert = buf.String() + + // If there is a prior CA to cross-sign with, then we need to create that + // and set it as the signing cert. + if xc != nil { + xccert, err := ParseCert(xc.RootCert) + if err != nil { + t.Fatalf("error parsing CA cert: %s", err) + } + xcsigner, err := ParseSigner(xc.SigningKey) + if err != nil { + t.Fatalf("error parsing signing key: %s", err) + } + + // Set the authority key to be the previous one + template.AuthorityKeyId = testKeyID(t, xcsigner.Public()) + + // Create the new certificate where the parent is the previous + // CA, the public key is the new public key, and the signing private + // key is the old private key. + bs, err := x509.CreateCertificate( + rand.Reader, &template, xccert, signer.Public(), xcsigner) + if err != nil { + t.Fatalf("error generating CA certificate: %s", err) + } + + var buf bytes.Buffer + err = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: bs}) + if err != nil { + t.Fatalf("error encoding private key: %s", err) + } + result.SigningCert = buf.String() + } + + return &result +} + +// TestLeaf returns a valid leaf certificate for the named service with +// the given CA Root. +func TestLeaf(t testing.T, service string, root *structs.CARoot) string { + // Parse the CA cert and signing key from the root + caCert, err := ParseCert(root.RootCert) + if err != nil { + t.Fatalf("error parsing CA cert: %s", err) + } + signer, err := ParseSigner(root.SigningKey) + if err != nil { + t.Fatalf("error parsing signing key: %s", err) + } + + // The serial number for the cert + sn, err := SerialNumber() + if err != nil { + t.Fatalf("error generating serial number: %s", err) + } + + // Cert template for generation + template := x509.Certificate{ + SerialNumber: sn, + Subject: pkix.Name{CommonName: service}, + SignatureAlgorithm: x509.ECDSAWithSHA256, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDataEncipherment | x509.KeyUsageKeyAgreement, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageClientAuth, + x509.ExtKeyUsageServerAuth, + }, + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + NotBefore: time.Now(), + AuthorityKeyId: testKeyID(t, signer.Public()), + SubjectKeyId: testKeyID(t, signer.Public()), + } + + // Create the certificate, PEM encode it and return that value. + var buf bytes.Buffer + bs, err := x509.CreateCertificate( + rand.Reader, &template, caCert, signer.Public(), signer) + if err != nil { + t.Fatalf("error generating certificate: %s", err) + } + err = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: bs}) + if err != nil { + t.Fatalf("error encoding private key: %s", err) + } + + return buf.String() +} + +// testKeyID returns a KeyID from the given public key. The "raw" must be +// an *ecdsa.PublicKey, but is an interface type to suppot crypto.Signer.Public +// values. +func testKeyID(t testing.T, raw interface{}) []byte { + pub, ok := raw.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("raw is type %T, expected *ecdsa.PublicKey", raw) + } + + // This is not standard; RFC allows any unique identifier as long as they + // match in subject/authority chains but suggests specific hashing of DER + // bytes of public key including DER tags. I can't be bothered to do esp. + // since ECDSA keys don't have a handy way to marshal the publick key alone. + h := sha256.New() + h.Write(pub.X.Bytes()) + h.Write(pub.Y.Bytes()) + return h.Sum([]byte{}) +} + +// testMemoizePK is the private key that we memoize once we generate it +// once so that our tests don't rely on too much system entropy. +var testMemoizePK atomic.Value + +// testPrivateKey creates an ECDSA based private key. +func testPrivateKey(t testing.T, ca *structs.CARoot) crypto.Signer { + // If we already generated a private key, use that + var pk *ecdsa.PrivateKey + if v := testMemoizePK.Load(); v != nil { + pk = v.(*ecdsa.PrivateKey) + } + + // If we have no key, then create a new one. + if pk == nil { + var err error + pk, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("error generating private key: %s", err) + } + } + + bs, err := x509.MarshalECPrivateKey(pk) + if err != nil { + t.Fatalf("error generating private key: %s", err) + } + + var buf bytes.Buffer + err = pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: bs}) + if err != nil { + t.Fatalf("error encoding private key: %s", err) + } + ca.SigningKey = buf.String() + + // Memoize the key + testMemoizePK.Store(pk) + + return pk +} diff --git a/connect/testing_test.go b/connect/testing_test.go new file mode 100644 index 0000000000..d07aac201a --- /dev/null +++ b/connect/testing_test.go @@ -0,0 +1,109 @@ +package connect + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// hasOpenSSL is used to determine if the openssl CLI exists for unit tests. +var hasOpenSSL bool + +func init() { + _, err := exec.LookPath("openssl") + hasOpenSSL = err == nil +} + +// Test that the TestCA and TestLeaf functions generate valid certificates. +func TestTestCAAndLeaf(t *testing.T) { + if !hasOpenSSL { + t.Skip("openssl not found") + return + } + + assert := assert.New(t) + + // Create the certs + ca := TestCA(t, nil) + leaf := TestLeaf(t, "web", ca) + + // Create a temporary directory for storing the certs + td, err := ioutil.TempDir("", "consul") + assert.Nil(err) + defer os.RemoveAll(td) + + // Write the cert + assert.Nil(ioutil.WriteFile(filepath.Join(td, "ca.pem"), []byte(ca.RootCert), 0644)) + assert.Nil(ioutil.WriteFile(filepath.Join(td, "leaf.pem"), []byte(leaf), 0644)) + + // Use OpenSSL to verify so we have an external, known-working process + // that can verify this outside of our own implementations. + cmd := exec.Command( + "openssl", "verify", "-verbose", "-CAfile", "ca.pem", "leaf.pem") + cmd.Dir = td + output, err := cmd.Output() + t.Log(string(output)) + assert.Nil(err) +} + +// Test cross-signing. +func TestTestCAAndLeaf_xc(t *testing.T) { + if !hasOpenSSL { + t.Skip("openssl not found") + return + } + + assert := assert.New(t) + + // Create the certs + ca1 := TestCA(t, nil) + ca2 := TestCA(t, ca1) + leaf1 := TestLeaf(t, "web", ca1) + leaf2 := TestLeaf(t, "web", ca2) + + // Create a temporary directory for storing the certs + td, err := ioutil.TempDir("", "consul") + assert.Nil(err) + defer os.RemoveAll(td) + + // Write the cert + xcbundle := []byte(ca1.RootCert) + xcbundle = append(xcbundle, '\n') + xcbundle = append(xcbundle, []byte(ca2.SigningCert)...) + assert.Nil(ioutil.WriteFile(filepath.Join(td, "ca.pem"), xcbundle, 0644)) + assert.Nil(ioutil.WriteFile(filepath.Join(td, "leaf1.pem"), []byte(leaf1), 0644)) + assert.Nil(ioutil.WriteFile(filepath.Join(td, "leaf2.pem"), []byte(leaf2), 0644)) + + // OpenSSL verify the cross-signed leaf (leaf2) + { + cmd := exec.Command( + "openssl", "verify", "-verbose", "-CAfile", "ca.pem", "leaf2.pem") + cmd.Dir = td + output, err := cmd.Output() + t.Log(string(output)) + assert.Nil(err) + } + + // OpenSSL verify the old leaf (leaf1) + { + cmd := exec.Command( + "openssl", "verify", "-verbose", "-CAfile", "ca.pem", "leaf1.pem") + cmd.Dir = td + output, err := cmd.Output() + t.Log(string(output)) + assert.Nil(err) + } +} + +// Test that the private key is memoized to preseve system entropy. +func TestTestPrivateKey_memoize(t *testing.T) { + ca1 := TestCA(t, nil) + ca2 := TestCA(t, nil) + if ca1.SigningKey != ca2.SigningKey { + t.Fatal("should have the same signing keys for tests") + } +}