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
}