From a98b85b25c9d82daa66546f677c416db4c935407 Mon Sep 17 00:00:00 2001 From: Kyle Havlovitz Date: Wed, 13 Jun 2018 01:40:03 -0700 Subject: [PATCH] connect/ca: add the Vault CA provider --- agent/config/builder.go | 28 +++ agent/connect/ca/provider_consul.go | 11 +- agent/connect/ca/provider_consul_test.go | 8 +- agent/connect/ca/provider_vault.go | 284 +++++++++++++++++++++++ agent/connect/ca/provider_vault_test.go | 81 +++++++ agent/connect_ca_endpoint.go | 15 +- agent/consul/leader.go | 2 + agent/structs/connect_ca.go | 8 + 8 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 agent/connect/ca/provider_vault.go create mode 100644 agent/connect/ca/provider_vault_test.go diff --git a/agent/config/builder.go b/agent/config/builder.go index 9e34d2fba..9d3a1cdc7 100644 --- a/agent/config/builder.go +++ b/agent/config/builder.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/hashicorp/consul/agent/connect/ca" "github.com/hashicorp/consul/agent/consul" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/ipaddr" @@ -533,9 +534,16 @@ func (b *Builder) Build() (rt RuntimeConfig, err error) { connectCAConfig := c.Connect.CAConfig if connectCAConfig != nil { TranslateKeys(connectCAConfig, map[string]string{ + // Consul CA config "private_key": "PrivateKey", "root_cert": "RootCert", "rotation_period": "RotationPeriod", + + // Vault CA config + "address": "Address", + "token": "Token", + "root_pki_path": "RootPKIPath", + "intermediate_pki_path": "IntermediatePKIPath", }) } @@ -925,6 +933,26 @@ func (b *Builder) Validate(rt RuntimeConfig) error { } } + // Validate the given Connect CA provider config + validCAProviders := map[string]bool{ + structs.ConsulCAProvider: true, + structs.VaultCAProvider: true, + } + if _, ok := validCAProviders[rt.ConnectCAProvider]; !ok { + return fmt.Errorf("%s is not a valid CA provider", rt.ConnectCAProvider) + } else { + switch rt.ConnectCAProvider { + case structs.ConsulCAProvider: + if _, err := ca.ParseConsulCAConfig(rt.ConnectCAConfig); err != nil { + return err + } + case structs.VaultCAProvider: + if _, err := ca.ParseVaultCAConfig(rt.ConnectCAConfig); err != nil { + return err + } + } + } + // ---------------------------------------------------------------- // warnings // diff --git a/agent/connect/ca/provider_consul.go b/agent/connect/ca/provider_consul.go index d2ece1811..d800215ff 100644 --- a/agent/connect/ca/provider_consul.go +++ b/agent/connect/ca/provider_consul.go @@ -128,16 +128,9 @@ func (c *ConsulProvider) ActiveIntermediate() (string, error) { } // We aren't maintaining separate root/intermediate CAs for the builtin -// provider, so just generate a CSR for the active root. +// provider, so just return the root. func (c *ConsulProvider) GenerateIntermediate() (string, error) { - ca, err := c.ActiveIntermediate() - if err != nil { - return "", err - } - - // todo(kyhavlov): make a new intermediate here - - return ca, err + return c.ActiveIntermediate() } // Remove the state store entry for this provider instance. diff --git a/agent/connect/ca/provider_consul_test.go b/agent/connect/ca/provider_consul_test.go index c3b375fc2..60166be36 100644 --- a/agent/connect/ca/provider_consul_test.go +++ b/agent/connect/ca/provider_consul_test.go @@ -67,7 +67,7 @@ func testConsulCAConfig() *structs.CAConfiguration { } } -func TestCAProvider_Bootstrap(t *testing.T) { +func TestConsulCAProvider_Bootstrap(t *testing.T) { t.Parallel() assert := assert.New(t) @@ -91,7 +91,7 @@ func TestCAProvider_Bootstrap(t *testing.T) { assert.Equal(parsed.URIs[0].String(), fmt.Sprintf("spiffe://%s.consul", conf.ClusterID)) } -func TestCAProvider_Bootstrap_WithCert(t *testing.T) { +func TestConsulCAProvider_Bootstrap_WithCert(t *testing.T) { t.Parallel() // Make sure setting a custom private key/root cert works. @@ -112,7 +112,7 @@ func TestCAProvider_Bootstrap_WithCert(t *testing.T) { assert.Equal(root, rootCA.RootCert) } -func TestCAProvider_SignLeaf(t *testing.T) { +func TestConsulCAProvider_SignLeaf(t *testing.T) { t.Parallel() assert := assert.New(t) @@ -174,7 +174,7 @@ func TestCAProvider_SignLeaf(t *testing.T) { } } -func TestCAProvider_CrossSignCA(t *testing.T) { +func TestConsulCAProvider_CrossSignCA(t *testing.T) { t.Parallel() assert := assert.New(t) diff --git a/agent/connect/ca/provider_vault.go b/agent/connect/ca/provider_vault.go new file mode 100644 index 000000000..24f90ac6f --- /dev/null +++ b/agent/connect/ca/provider_vault.go @@ -0,0 +1,284 @@ +package ca + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/hashicorp/consul/agent/structs" + vaultapi "github.com/hashicorp/vault/api" + "github.com/mitchellh/mapstructure" +) + +const VaultCALeafCertRole = "leaf-cert" + +var ErrBackendNotMounted = fmt.Errorf("backend not mounted") +var ErrBackendNotInitialized = fmt.Errorf("backend not initialized") + +type VaultProvider struct { + config *structs.VaultCAProviderConfig + client *vaultapi.Client + clusterId string +} + +// NewVaultProvider returns a vault provider with its root and intermediate PKI +// backends mounted and initialized. If the root backend is not set up already, +// it will be mounted/generated as needed, but any existing state will not be +// overwritten. +func NewVaultProvider(rawConfig map[string]interface{}, clusterId string, client *vaultapi.Client) (*VaultProvider, error) { + conf, err := ParseVaultCAConfig(rawConfig) + if err != nil { + return nil, err + } + + // todo(kyhavlov): figure out the right way to pass the TLS config + if client == nil { + clientConf := &vaultapi.Config{ + Address: conf.Address, + } + client, err = vaultapi.NewClient(clientConf) + if err != nil { + return nil, err + } + + client.SetToken(conf.Token) + } + + provider := &VaultProvider{ + config: conf, + client: client, + clusterId: clusterId, + } + + // Set up the root PKI backend if necessary. + _, err = provider.ActiveRoot() + switch err { + case ErrBackendNotMounted: + err := client.Sys().Mount(conf.RootPKIPath, &vaultapi.MountInput{ + Type: "pki", + Description: "root CA backend for Consul Connect", + Config: vaultapi.MountConfigInput{ + MaxLeaseTTL: "8760h", + }, + }) + + if err != nil { + return nil, err + } + + fallthrough + case ErrBackendNotInitialized: + _, err := client.Logical().Write(conf.RootPKIPath+"root/generate/internal", map[string]interface{}{ + "alt_names": fmt.Sprintf("URI:spiffe://%s.consul", clusterId), + }) + if err != nil { + return nil, err + } + default: + if err != nil { + return nil, err + } + } + + // Set up the intermediate backend. + if _, err := provider.GenerateIntermediate(); err != nil { + return nil, err + } + + return provider, nil +} + +func (v *VaultProvider) ActiveRoot() (string, error) { + return v.getCA(v.config.RootPKIPath) +} + +func (v *VaultProvider) ActiveIntermediate() (string, error) { + return v.getCA(v.config.IntermediatePKIPath) +} + +// getCA returns the raw CA cert for the given endpoint if there is one. +// We have to use the raw NewRequest call here instead of Logical().Read +// because the endpoint only returns the raw PEM contents of the CA cert +// and not the typical format of the secrets endpoints. +func (v *VaultProvider) getCA(path string) (string, error) { + req := v.client.NewRequest("GET", "/v1/"+path+"/ca/pem") + resp, err := v.client.RawRequest(req) + if resp != nil { + defer resp.Body.Close() + } + if resp != nil && resp.StatusCode == http.StatusNotFound { + return "", ErrBackendNotMounted + } + if err != nil { + return "", err + } + + bytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + root := string(bytes) + if root == "" { + return "", ErrBackendNotInitialized + } + + return root, nil +} + +// GenerateIntermediate mounts the configured intermediate PKI backend if +// necessary, then generates and signs a new CA CSR using the root PKI backend +// and updates the intermediate backend to use that new certificate. +func (v *VaultProvider) GenerateIntermediate() (string, error) { + mounts, err := v.client.Sys().ListMounts() + if err != nil { + return "", err + } + + // Mount the backend if it isn't mounted already. + if _, ok := mounts[v.config.IntermediatePKIPath]; !ok { + err := v.client.Sys().Mount(v.config.IntermediatePKIPath, &vaultapi.MountInput{ + Type: "pki", + Description: "intermediate CA backend for Consul Connect", + Config: vaultapi.MountConfigInput{ + MaxLeaseTTL: "2160h", + }, + }) + + if err != nil { + return "", err + } + } + + // Create the role for issuing leaf certs if it doesn't exist yet + rolePath := v.config.IntermediatePKIPath + "roles/" + VaultCALeafCertRole + role, err := v.client.Logical().Read(rolePath) + if err != nil { + return "", err + } + if role == nil { + _, err := v.client.Logical().Write(rolePath, map[string]interface{}{ + "allowed_domains": fmt.Sprintf("%s.consul", v.clusterId), + "max_ttl": "72h", + }) + if err != nil { + return "", err + } + } + + // Generate a new intermediate CSR for the root to sign. + csr, err := v.client.Logical().Write(v.config.IntermediatePKIPath+"intermediate/generate/internal", map[string]interface{}{ + "common_name": "Vault CA Intermediate Authority", + "alt_names": fmt.Sprintf("URI:spiffe://%s.consul", v.clusterId), + }) + if err != nil { + return "", err + } + if csr == nil || csr.Data["csr"] == nil { + return "", fmt.Errorf("got empty value when generating intermediate CSR") + } + + // Sign the CSR with the root backend. + intermediate, err := v.client.Logical().Write(v.config.RootPKIPath+"root/sign-intermediate", map[string]interface{}{ + "csr": csr.Data["csr"], + "format": "pem_bundle", + }) + if err != nil { + return "", err + } + if intermediate == nil || intermediate.Data["certificate"] == nil { + return "", fmt.Errorf("got empty value when generating intermediate certificate") + } + + // Set the intermediate backend to use the new certificate. + _, err = v.client.Logical().Write(v.config.IntermediatePKIPath+"intermediate/set-signed", map[string]interface{}{ + "certificate": intermediate.Data["certificate"], + }) + if err != nil { + return "", err + } + + return v.ActiveIntermediate() +} + +// Sign calls the configured role in the intermediate PKI backend to issue +// a new leaf certificate based on the provided CSR, with the issuing +// intermediate CA cert attached. +func (v *VaultProvider) Sign(csr *x509.CertificateRequest) (string, error) { + var pemBuf bytes.Buffer + if err := pem.Encode(&pemBuf, &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr.Raw}); err != nil { + return "", err + } + + // Use the leaf cert role to sign a new cert for this CSR. + response, err := v.client.Logical().Write(v.config.IntermediatePKIPath+"sign/"+VaultCALeafCertRole, map[string]interface{}{ + "csr": pemBuf.String(), + "common_name": csr.Subject.CommonName, + }) + if err != nil { + return "", nil + } + if response == nil || response.Data["certificate"] == "" || response.Data["issuing_ca"] == "" { + return "", fmt.Errorf("certificate info returned from Vault was blank") + } + + cert, ok := response.Data["certificate"].(string) + if !ok { + return "", fmt.Errorf("certificate was not a string") + } + ca, ok := response.Data["issuing_ca"].(string) + if !ok { + return "", fmt.Errorf("issuing_ca was not a string") + } + + return fmt.Sprintf("%s\n%s", cert, ca), nil +} + +// todo(kyhavlov): decide which vault endpoint to use here +func (v *VaultProvider) CrossSignCA(cert *x509.Certificate) (string, error) { + var pemBuf bytes.Buffer + if err := pem.Encode(&pemBuf, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}); err != nil { + return "", err + } + + return pemBuf.String(), nil +} + +// Cleanup unmounts the configured intermediate PKI backend. It's fine to tear +// this down and recreate it on small config changes because the intermediate +// certs get bundled with the leaf certs, so there's no cost to the CA changing. +func (v *VaultProvider) Cleanup() error { + return v.client.Sys().Unmount(v.config.IntermediatePKIPath) +} + +func ParseVaultCAConfig(raw map[string]interface{}) (*structs.VaultCAProviderConfig, error) { + var config structs.VaultCAProviderConfig + + if err := mapstructure.Decode(raw, &config); err != nil { + return nil, fmt.Errorf("error decoding config: %s", err) + } + + if config.Token == "" { + return nil, fmt.Errorf("must provide a Vault token") + } + + if config.RootPKIPath == "" { + return nil, fmt.Errorf("must provide a valid path to a root PKI backend") + } + if !strings.HasSuffix(config.RootPKIPath, "/") { + config.RootPKIPath += "/" + } + + if config.IntermediatePKIPath == "" { + return nil, fmt.Errorf("must provide a valid path for the intermediate PKI backend") + } + if !strings.HasSuffix(config.IntermediatePKIPath, "/") { + config.IntermediatePKIPath += "/" + } + + return &config, nil +} diff --git a/agent/connect/ca/provider_vault_test.go b/agent/connect/ca/provider_vault_test.go new file mode 100644 index 000000000..b923469fd --- /dev/null +++ b/agent/connect/ca/provider_vault_test.go @@ -0,0 +1,81 @@ +package ca + +import ( + "fmt" + "io/ioutil" + "testing" + + "github.com/hashicorp/consul/agent/connect" + "github.com/hashicorp/vault/builtin/logical/pki" + vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" +) + +func testVaultCluster(t *testing.T) (*VaultProvider, *vault.TestCluster) { + coreConfig := &vault.CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + "pki": pki.Factory, + }, + } + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + NumCores: 1, + }) + cluster.Start() + + client := cluster.Cores[0].Client + + provider, err := NewVaultProvider(map[string]interface{}{ + "Address": client.Address(), + "Token": cluster.RootToken, + "RootPKIPath": "pki-root/", + "IntermediatePKIPath": "pki-intermediate/", + }, "asdf", client) + if err != nil { + t.Fatal(err) + } + + return provider, cluster +} + +func TestVaultCAProvider_Bootstrap(t *testing.T) { + t.Parallel() + + require := require.New(t) + provider, vaultCluster := testVaultCluster(t) + defer vaultCluster.Cleanup() + client := vaultCluster.Cores[0].Client + + cases := []struct { + certFunc func() (string, error) + backendPath string + }{ + { + certFunc: provider.ActiveRoot, + backendPath: "pki-root/", + }, + { + certFunc: provider.ActiveIntermediate, + backendPath: "pki-intermediate/", + }, + } + + // Verify the root and intermediate certs match the ones in the vault backends + for _, tc := range cases { + cert, err := tc.certFunc() + require.NoError(err) + req := client.NewRequest("GET", "v1/"+tc.backendPath+"ca/pem") + resp, err := client.RawRequest(req) + require.NoError(err) + bytes, err := ioutil.ReadAll(resp.Body) + require.NoError(err) + require.Equal(cert, string(bytes)) + + // Should be a valid cert + parsed, err := connect.ParseCert(cert) + require.NoError(err) + require.Equal(parsed.URIs[0].String(), fmt.Sprintf("spiffe://%s.consul", provider.clusterId)) + } +} diff --git a/agent/connect_ca_endpoint.go b/agent/connect_ca_endpoint.go index ea984ed37..7121bd71f 100644 --- a/agent/connect_ca_endpoint.go +++ b/agent/connect_ca_endpoint.go @@ -79,14 +79,15 @@ func (s *HTTPServer) ConnectCAConfigurationSet(resp http.ResponseWriter, req *ht // string values that get converted to []uint8 end up getting output back // to the user in base64-encoded form. func fixupConfig(conf *structs.CAConfiguration) { - if conf.Provider == structs.ConsulCAProvider { - for k, v := range conf.Config { - if raw, ok := v.([]uint8); ok { - conf.Config[k] = ca.Uint8ToString(raw) - } + for k, v := range conf.Config { + if raw, ok := v.([]uint8); ok { + conf.Config[k] = ca.Uint8ToString(raw) } - if v, ok := conf.Config["PrivateKey"]; ok && v != "" { - conf.Config["PrivateKey"] = "hidden" + // todo(kyhavlov): should we be hiding this and the vault token? + if conf.Provider == structs.ConsulCAProvider { + if v, ok := conf.Config["PrivateKey"]; ok && v != "" { + conf.Config["PrivateKey"] = "hidden" + } } } } diff --git a/agent/consul/leader.go b/agent/consul/leader.go index e1ab7dfbe..abac2f330 100644 --- a/agent/consul/leader.go +++ b/agent/consul/leader.go @@ -511,6 +511,8 @@ func (s *Server) createCAProvider(conf *structs.CAConfiguration) (ca.Provider, e switch conf.Provider { case structs.ConsulCAProvider: return ca.NewConsulProvider(conf.Config, &consulCADelegate{s}) + case structs.VaultCAProvider: + return ca.NewVaultProvider(conf.Config, conf.ClusterID, nil) default: return nil, fmt.Errorf("unknown CA provider %q", conf.Provider) } diff --git a/agent/structs/connect_ca.go b/agent/structs/connect_ca.go index fa273a3e4..2577a99a6 100644 --- a/agent/structs/connect_ca.go +++ b/agent/structs/connect_ca.go @@ -168,6 +168,7 @@ func (q *CARequest) RequestDatacenter() string { const ( ConsulCAProvider = "consul" + VaultCAProvider = "vault" ) // CAConfiguration is the configuration for the current CA plugin. @@ -200,3 +201,10 @@ type CAConsulProviderState struct { RaftIndex } + +type VaultCAProviderConfig struct { + Address string + Token string + RootPKIPath string + IntermediatePKIPath string +}