You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by ma...@apache.org on 2021/05/19 19:14:57 UTC

[trafficcontrol] branch master updated: Provide ability to fetch Traffic Vault secret key from HashiCorp Vault (#5865)

This is an automated email from the ASF dual-hosted git repository.

mattjackson pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new b1d440a  Provide ability to fetch Traffic Vault secret key from HashiCorp Vault (#5865)
b1d440a is described below

commit b1d440acf7d3c57a676e48cdeac72ea497b42922
Author: Rawlin Peters <ra...@apache.org>
AuthorDate: Wed May 19 13:14:40 2021 -0600

    Provide ability to fetch Traffic Vault secret key from HashiCorp Vault (#5865)
    
    * Provide ability to fetch Traffic Vault secret key from HashiCorp Vault
    
    * Check for non-nil but empty HashiCorpVault struct
---
 docs/source/admin/traffic_vault.rst                |  12 +-
 lib/go-rfc/http.go                                 |   1 +
 .../trafficvault/backends/postgres/encrypt.go      |  41 ++++-
 .../backends/postgres/hashicorpvault/client.go     | 184 +++++++++++++++++++++
 .../trafficvault/backends/postgres/postgres.go     |  63 +++++--
 5 files changed, 279 insertions(+), 22 deletions(-)

diff --git a/docs/source/admin/traffic_vault.rst b/docs/source/admin/traffic_vault.rst
index 86025bb..0ae9846 100644
--- a/docs/source/admin/traffic_vault.rst
+++ b/docs/source/admin/traffic_vault.rst
@@ -33,7 +33,17 @@ In order to use the PostgreSQL backend for Traffic Vault, you will need to set t
 :password:                  The password to use when connecting to the database
 :port:                      The port number that the database listens for new connections on (NOTE: the PostgreSQL default is 5432)
 :user:                      The username to use when connecting to the database
-:aes_key_location:          The location on-disk for an AES, base64 encoded key used to encrypt secrets before they are stored.
+:aes_key_location:          The location on-disk for a base64-encoded AES key used to encrypt secrets before they are stored. It is highly recommended to backup this key to a safe, secure storage location, because if it is lost, you will lose access to all your Traffic Vault data. Either this option or ``hashicorp_vault`` must be used.
+:hashicorp_vault:           This group of configuration options is for fetching the base64-encoded AES key from `HashiCorp Vault <https://www.vaultproject.io/>`_. This uses the `AppRole authentication method <https://learn.hashicorp.com/tutorials/vault/approle>`_.
+
+	:address:     The address of the HashiCorp Vault server, e.g. http://localhost:8200
+	:role_id:     The RoleID of the AppRole.
+	:secret_id:   The SecretID issued against the AppRole.
+	:secret_path: The URI path where the secret AES key is located, e.g. /v1/secret/data/trafficvault. The secret should be stored using the `KV Secrets Engine <https://www.vaultproject.io/docs/secrets/kv>`_ with a key of ``traffic_vault_key`` and value of a base64-encoded AES key, e.g. ``traffic_vault_key='WoFc86CisM1aXo8D5GvDnq2h9kjULuIP4upaqX15SRc='``.
+	:login_path:  Optional. The URI path used to login with the AppRole method. Default: /v1/auth/approle/login
+	:timeout_sec: Optional. The timeout (in seconds) for requests. Default: 30
+	:insecure:    Optional. Disable server certificate verification. This should only be used for testing purposes. Default: false
+
 :conn_max_lifetime_seconds: Optional. The maximum amount of time (in seconds) a connection may be reused. If negative, connections are not closed due to a connection's age. If 0 or unset, the default of 60 is used.
 :max_connections:           Optional. The maximum number of open connections to the database. Default: 0 (unlimited)
 :max_idle_connections:      Optional. The maximum number of connections in the idle connection pool. If negative, no idle connections are retained. If 0 or unset, the default of 30 is used.
diff --git a/lib/go-rfc/http.go b/lib/go-rfc/http.go
index fb87ec4..03979fe 100644
--- a/lib/go-rfc/http.go
+++ b/lib/go-rfc/http.go
@@ -38,6 +38,7 @@ const (
 	ContentType        = "Content-Type"        // RFC7231§3.1.1.5
 	PermissionsPolicy  = "Permissions-Policy"  // W3C "Permissions Policy"
 	Server             = "Server"              // RFC7231§7.4.2
+	UserAgent          = "User-Agent"          // RFC7231§5.5.3
 	Vary               = "Vary"                // RFC7231§7.1.4
 )
 
diff --git a/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/encrypt.go b/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/encrypt.go
index c110807..9f30d5f 100644
--- a/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/encrypt.go
+++ b/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/encrypt.go
@@ -27,6 +27,9 @@ import (
 	"errors"
 	"io"
 	"io/ioutil"
+	"time"
+
+	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/hashicorpvault"
 )
 
 func aesEncrypt(bytesToEncrypt []byte, aesKey []byte) (string, error) {
@@ -73,17 +76,39 @@ func aesDecrypt(bytesToDecrypt []byte, aesKey []byte) ([]byte, error) {
 	return decryptedString, nil
 }
 
-func readKeyFromFile(fileLocation string) ([]byte, error) {
-	keyBase64, err := ioutil.ReadFile(fileLocation)
-	if err != nil {
-		return []byte{}, errors.New("reading file '" + fileLocation + "':" + err.Error())
+// readKey reads the AES key (encoded in base64) used for encryption/decryption from either an on-disk file
+// or from HashiCorp Vault (based on the given configuration).
+func readKey(cfg Config) ([]byte, error) {
+	var keyBase64 string
+	if cfg.AesKeyLocation != "" {
+		keyBase64Bytes, err := ioutil.ReadFile(cfg.AesKeyLocation)
+		if err != nil {
+			return []byte{}, errors.New("reading file '" + cfg.AesKeyLocation + "':" + err.Error())
+		}
+		keyBase64 = string(keyBase64Bytes)
+	} else {
+		hashiVault := hashicorpvault.NewClient(
+			cfg.HashiCorpVault.Address,
+			cfg.HashiCorpVault.RoleID,
+			cfg.HashiCorpVault.SecretID,
+			cfg.HashiCorpVault.LoginPath,
+			cfg.HashiCorpVault.SecretPath,
+			time.Duration(cfg.HashiCorpVault.TimeoutSec)*time.Second,
+			cfg.HashiCorpVault.Insecure,
+		)
+		if err := hashiVault.Login(); err != nil {
+			return nil, errors.New("failed to login to HashiCorp Vault: " + err.Error())
+		}
+		key, err := hashiVault.GetSecret()
+		if err != nil {
+			return nil, errors.New("failed to get AES key from HashiCorp Vault: " + err.Error())
+		}
+		keyBase64 = key
 	}
 
-	keyBase64String := string(keyBase64)
-
-	key, err := base64.StdEncoding.DecodeString(keyBase64String)
+	key, err := base64.StdEncoding.DecodeString(keyBase64)
 	if err != nil {
-		return []byte{}, errors.New("AES key cannot be decoded")
+		return []byte{}, errors.New("AES key cannot be decoded from base64")
 	}
 
 	// verify the key works
diff --git a/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/hashicorpvault/client.go b/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/hashicorpvault/client.go
new file mode 100644
index 0000000..0254887
--- /dev/null
+++ b/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/hashicorpvault/client.go
@@ -0,0 +1,184 @@
+package hashicorpvault
+
+/*
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+import (
+	"bytes"
+	"crypto/tls"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptrace"
+	"strings"
+	"time"
+
+	"github.com/apache/trafficcontrol/lib/go-log"
+	"github.com/apache/trafficcontrol/lib/go-rfc"
+)
+
+const (
+	defaultTimeout   = 30 * time.Second
+	userAgent        = "TrafficOps/6.0"
+	vaultTokenHeader = "X-Vault-Token"
+)
+
+type Client struct {
+	address    string
+	roleID     string
+	secretID   string
+	token      string
+	httpClient *http.Client
+	loginPath  string
+	secretPath string
+}
+
+func NewClient(address, roleID, secretID, loginPath, secretPath string, timeout time.Duration, insecure bool) *Client {
+	if timeout == 0 {
+		timeout = defaultTimeout
+	}
+	res := Client{
+		address:  address,
+		roleID:   roleID,
+		secretID: secretID,
+		httpClient: &http.Client{
+			Timeout: timeout,
+			Transport: &http.Transport{
+				TLSClientConfig:     &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12},
+				TLSHandshakeTimeout: 10 * time.Second,
+			},
+		},
+		loginPath:  loginPath,
+		secretPath: secretPath,
+	}
+	return &res
+}
+
+type appRoleLoginRequest struct {
+	RoleID   string `json:"role_id"`
+	SecretID string `json:"secret_id"`
+}
+
+type appRoleLoginResponse struct {
+	Auth   auth     `json:"auth"`
+	Errors []string `json:"errors"`
+}
+
+type auth struct {
+	ClientToken string `json:"client_token"`
+}
+
+func (c *Client) Login() error {
+	data := appRoleLoginRequest{
+		RoleID:   c.roleID,
+		SecretID: c.secretID,
+	}
+	body, err := json.Marshal(data)
+	if err != nil {
+		return errors.New("marshalling login request body: " + err.Error())
+	}
+	requestURL := c.getURL(c.loginPath)
+	resp, remoteAddr, err := c.doRequest(http.MethodPost, requestURL, body)
+	if err != nil {
+		return fmt.Errorf("doing login HTTP request (addr = %s): %s", remoteAddr, err.Error())
+	}
+	defer log.Close(resp.Body, "closing HashiCorp Vault login response body")
+	loginResp := appRoleLoginResponse{}
+	err = json.NewDecoder(resp.Body).Decode(&loginResp)
+	if err != nil {
+		return fmt.Errorf("decoding HashCorp Vault login response body (addr = %s): %s", remoteAddr, err.Error())
+	}
+	if !(200 <= resp.StatusCode && resp.StatusCode <= 299) {
+		errs := strings.Join(loginResp.Errors, ", ")
+		return fmt.Errorf("login attempt (addr = %s) returned status code: %s, errors: %s", remoteAddr, resp.Status, errs)
+	}
+	if loginResp.Auth.ClientToken == "" {
+		return fmt.Errorf("login response body contained empty auth.client_token (addr = %s)", remoteAddr)
+	}
+	c.token = loginResp.Auth.ClientToken
+	log.Infof("successfully authenticated to HashiCorp Vault (addr = %s)", remoteAddr)
+	return nil
+}
+
+type secretResponse struct {
+	Data   secretData `json:"data"`
+	Errors []string   `json:"errors"`
+}
+
+type secretData struct {
+	Data secretKeyValue `json:"data"`
+}
+
+type secretKeyValue struct {
+	TrafficVaultKey string `json:"traffic_vault_key"`
+}
+
+func (c *Client) GetSecret() (string, error) {
+	requestURL := c.getURL(c.secretPath)
+	resp, remoteAddr, err := c.doRequest(http.MethodGet, requestURL, nil)
+	if err != nil {
+		return "", fmt.Errorf("doing secret HTTP request (addr = %s): %s", remoteAddr, err.Error())
+	}
+	defer log.Close(resp.Body, "closing HashiCorp Vault secret response body")
+	secretResp := secretResponse{}
+	err = json.NewDecoder(resp.Body).Decode(&secretResp)
+	if err != nil {
+		return "", fmt.Errorf("decoding HashCorp Vault secret response body (addr = %s): %s", remoteAddr, err.Error())
+	}
+	if !(200 <= resp.StatusCode && resp.StatusCode <= 299) {
+		errs := strings.Join(secretResp.Errors, ", ")
+		return "", fmt.Errorf("attempting to get secret (addr = %s) returned status code: %s, errors: %s", remoteAddr, resp.Status, errs)
+	}
+	if secretResp.Data.Data.TrafficVaultKey == "" {
+		return "", fmt.Errorf("secret response body contained empty traffic_vault_key (addr = %s)", remoteAddr)
+	}
+	log.Infof("successfully retrieved secret traffic_vault_key from HashiCorp Vault (addr = %s)", remoteAddr)
+	return secretResp.Data.Data.TrafficVaultKey, nil
+}
+
+func (c *Client) doRequest(method, url string, body []byte) (*http.Response, string, error) {
+	remoteAddr := ""
+	var resp *http.Response
+	var req *http.Request
+	var err error
+	if body != nil {
+		req, err = http.NewRequest(method, url, bytes.NewBuffer(body))
+		if err != nil {
+			return nil, "", errors.New("creating http request: " + err.Error())
+		}
+		req.Header.Set(rfc.ContentType, rfc.ApplicationJSON)
+	} else {
+		req, err = http.NewRequest(method, url, nil)
+		if err != nil {
+			return nil, "", errors.New("creating http request: " + err.Error())
+		}
+	}
+	trace := &httptrace.ClientTrace{
+		GotConn: func(connInfo httptrace.GotConnInfo) {
+			remoteAddr = connInfo.Conn.RemoteAddr().String()
+		},
+	}
+	req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
+	req.Header.Set(rfc.UserAgent, userAgent)
+	if c.token != "" {
+		req.Header.Set(vaultTokenHeader, c.token)
+	}
+	resp, err = c.httpClient.Do(req)
+	return resp, remoteAddr, err
+}
+
+func (c *Client) getURL(path string) string {
+	return strings.TrimSuffix(c.address, "/") + "/" + strings.TrimPrefix(path, "/")
+}
diff --git a/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/postgres.go b/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/postgres.go
index 7e4e669..667985e 100644
--- a/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/postgres.go
+++ b/traffic_ops/traffic_ops_golang/trafficvault/backends/postgres/postgres.go
@@ -36,6 +36,7 @@ import (
 	"github.com/apache/trafficcontrol/traffic_ops/traffic_ops_golang/trafficvault"
 
 	validation "github.com/go-ozzo/ozzo-validation"
+	"github.com/go-ozzo/ozzo-validation/is"
 	"github.com/jmoiron/sqlx"
 	"github.com/lib/pq"
 )
@@ -55,21 +56,35 @@ const (
 	defaultConnMaxLifetimeSeconds = 60
 	defaultDBQueryTimeoutSecs     = 30
 
+	defaultHashiCorpVaultLoginPath  = "/v1/auth/approle/login"
+	defaultHashiCorpVaultTimeoutSec = 30
+
 	latestVersion = "latest"
 )
 
 type Config struct {
-	DBName                 string `json:"dbname"`
-	Hostname               string `json:"hostname"`
-	User                   string `json:"user"`
-	Password               string `json:"password"`
-	Port                   int    `json:"port"`
-	SSL                    bool   `json:"ssl"`
-	MaxConnections         int    `json:"max_connections"`
-	MaxIdleConnections     int    `json:"max_idle_connections"`
-	ConnMaxLifetimeSeconds int    `json:"conn_max_lifetime_seconds"`
-	QueryTimeoutSeconds    int    `json:"query_timeout_seconds"`
-	AesKeyLocation         string `json:"aes_key_location"`
+	DBName                 string          `json:"dbname"`
+	Hostname               string          `json:"hostname"`
+	User                   string          `json:"user"`
+	Password               string          `json:"password"`
+	Port                   int             `json:"port"`
+	SSL                    bool            `json:"ssl"`
+	MaxConnections         int             `json:"max_connections"`
+	MaxIdleConnections     int             `json:"max_idle_connections"`
+	ConnMaxLifetimeSeconds int             `json:"conn_max_lifetime_seconds"`
+	QueryTimeoutSeconds    int             `json:"query_timeout_seconds"`
+	AesKeyLocation         string          `json:"aes_key_location"`
+	HashiCorpVault         *HashiCorpVault `json:"hashicorp_vault"`
+}
+
+type HashiCorpVault struct {
+	Address    string `json:"address"`
+	RoleID     string `json:"role_id"`
+	SecretID   string `json:"secret_id"`
+	LoginPath  string `json:"login_path"`
+	SecretPath string `json:"secret_path"`
+	TimeoutSec int    `json:"timeout_sec"`
+	Insecure   bool   `json:"insecure"`
 }
 
 type Postgres struct {
@@ -443,6 +458,14 @@ func postgresLoad(b json.RawMessage) (trafficvault.TrafficVault, error) {
 	if pgCfg.QueryTimeoutSeconds == 0 {
 		pgCfg.QueryTimeoutSeconds = defaultDBQueryTimeoutSecs
 	}
+	if pgCfg.HashiCorpVault != nil {
+		if pgCfg.HashiCorpVault.LoginPath == "" {
+			pgCfg.HashiCorpVault.LoginPath = defaultHashiCorpVaultLoginPath
+		}
+		if pgCfg.HashiCorpVault.TimeoutSec == 0 {
+			pgCfg.HashiCorpVault.TimeoutSec = defaultHashiCorpVaultTimeoutSec
+		}
+	}
 
 	sslStr := "require"
 	if !pgCfg.SSL {
@@ -465,7 +488,7 @@ func postgresLoad(b json.RawMessage) (trafficvault.TrafficVault, error) {
 		log.Infoln("successfully pinged the Traffic Vault database")
 	}
 
-	aesKey, err := readKeyFromFile(pgCfg.AesKeyLocation)
+	aesKey, err := readKey(pgCfg)
 	if err != nil {
 		return nil, err
 	}
@@ -482,8 +505,22 @@ func validateConfig(cfg Config) error {
 		"port":                  validation.Validate(cfg.Port, validation.By(tovalidate.IsValidPortNumber)),
 		"max_connections":       validation.Validate(cfg.MaxConnections, validation.Min(0)),
 		"query_timeout_seconds": validation.Validate(cfg.QueryTimeoutSeconds, validation.Min(0)),
-		"aes_key_location":      validation.Validate(cfg.AesKeyLocation, validation.Required),
 	})
+	aesKeyLocSet := cfg.AesKeyLocation != ""
+	hashiCorpVaultSet := cfg.HashiCorpVault != nil && *cfg.HashiCorpVault != HashiCorpVault{}
+	if aesKeyLocSet && hashiCorpVaultSet {
+		errs = append(errs, errors.New("aes_key_location and hashicorp_vault cannot both be set"))
+	} else if hashiCorpVaultSet {
+		hashiErrs := tovalidate.ToErrors(validation.Errors{
+			"address":     validation.Validate(cfg.HashiCorpVault.Address, validation.Required, is.URL),
+			"role_id":     validation.Validate(cfg.HashiCorpVault.RoleID, validation.Required),
+			"secret_id":   validation.Validate(cfg.HashiCorpVault.SecretID, validation.Required),
+			"secret_path": validation.Validate(cfg.HashiCorpVault.SecretPath, validation.Required),
+		})
+		errs = append(errs, hashiErrs...)
+	} else if !aesKeyLocSet {
+		errs = append(errs, errors.New("one of either aes_key_location or hashicorp_vault is required"))
+	}
 	if len(errs) == 0 {
 		return nil
 	}