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 <[email protected]>
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
        }

Reply via email to