connect/ca: add the Vault CA provider

This commit is contained in:
Kyle Havlovitz 2018-06-13 01:40:03 -07:00 committed by Jack Pearkes
parent 6ecc0c8099
commit a98b85b25c
8 changed files with 417 additions and 20 deletions

View File

@ -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
//

View File

@ -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.

View File

@ -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)

View File

@ -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
}

View File

@ -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))
}
}

View File

@ -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)
}
}
// 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"
}
}
}
}

View File

@ -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)
}

View File

@ -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
}