/* Copyright 2021 The Dapr Authors 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. */ package vault import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "reflect" "strings" jsoniter "github.com/json-iterator/go" "golang.org/x/net/http2" "github.com/dapr/components-contrib/metadata" "github.com/dapr/components-contrib/secretstores" "github.com/dapr/kit/logger" kitmd "github.com/dapr/kit/metadata" ) const ( defaultVaultAddress string = "https://127.0.0.1:8200" defaultVaultEnginePath string = "secret" componentVaultAddress string = "vaultAddr" componentCaCert string = "caCert" componentCaPath string = "caPath" componentCaPem string = "caPem" componentSkipVerify string = "skipVerify" componentTLSServerName string = "tlsServerName" componentVaultToken string = "vaultToken" componentVaultTokenMountPath string = "vaultTokenMountPath" componentVaultKVPrefix string = "vaultKVPrefix" componentVaultKVUsePrefix string = "vaultKVUsePrefix" defaultVaultKVPrefix string = "dapr" vaultHTTPHeader string = "X-Vault-Token" vaultHTTPRequestHeader string = "X-Vault-Request" vaultEnginePath string = "enginePath" vaultValueType string = "vaultValueType" versionID string = "version_id" DataStr string = "data" ) type valueType string const ( valueTypeMap valueType = "map" valueTypeText valueType = "text" ) var _ secretstores.SecretStore = (*vaultSecretStore)(nil) func (v valueType) isMapType() bool { return v == valueTypeMap } var ErrNotFound = errors.New("secret key or version not exist") // vaultSecretStore is a secret store implementation for HashiCorp Vault. type vaultSecretStore struct { client *http.Client vaultAddress string vaultToken string vaultTokenMountPath string vaultKVPrefix string vaultEnginePath string vaultValueType valueType json jsoniter.API logger logger.Logger } type VaultMetadata struct { CaCert string CaPath string CaPem string SkipVerify string TLSServerName string VaultAddr string VaultKVPrefix string VaultKVUsePrefix bool VaultToken string VaultTokenMountPath string EnginePath string VaultValueType string } // tlsConfig is TLS configuration to interact with HashiCorp Vault. type tlsConfig struct { vaultCAPem string vaultCACert string vaultCAPath string vaultSkipVerify bool vaultServerName string } // vaultKVResponse is the response data from Vault KV. type vaultKVResponse struct { Data struct { Data map[string]string `json:"data"` } `json:"data"` } // vaultListKVResponse is the response data from Vault KV. type vaultListKVResponse struct { Data struct { Keys []string `json:"keys"` } `json:"data"` } // NewHashiCorpVaultSecretStore returns a new HashiCorp Vault secret store. func NewHashiCorpVaultSecretStore(logger logger.Logger) secretstores.SecretStore { return &vaultSecretStore{ client: &http.Client{}, logger: logger, json: jsoniter.ConfigFastest, } } // Init creates a HashiCorp Vault client. func (v *vaultSecretStore) Init(_ context.Context, meta secretstores.Metadata) error { m := VaultMetadata{ VaultKVUsePrefix: true, } err := kitmd.DecodeMetadata(meta.Properties, &m) if err != nil { return err } // Get Vault address address := m.VaultAddr if address == "" { address = defaultVaultAddress } v.vaultAddress = address v.vaultEnginePath = defaultVaultEnginePath if m.EnginePath != "" { v.vaultEnginePath = m.EnginePath } v.vaultValueType = valueTypeMap if m.VaultValueType != "" { switch valueType(m.VaultValueType) { case valueTypeMap: case valueTypeText: v.vaultValueType = valueTypeText default: return fmt.Errorf("vault init error, invalid value type %s, accepted values are map or text", m.VaultValueType) } } v.vaultToken = m.VaultToken v.vaultTokenMountPath = m.VaultTokenMountPath initErr := v.initVaultToken() if initErr != nil { return initErr } vaultKVPrefix := m.VaultKVPrefix if !m.VaultKVUsePrefix { vaultKVPrefix = "" } else if vaultKVPrefix == "" { vaultKVPrefix = defaultVaultKVPrefix } v.vaultKVPrefix = vaultKVPrefix // Generate TLS config tlsConf := metadataToTLSConfig(&m) client, err := v.createHTTPClient(tlsConf) if err != nil { return fmt.Errorf("couldn't create client using config: %w", err) } v.client = client return nil } func metadataToTLSConfig(meta *VaultMetadata) *tlsConfig { tlsConf := tlsConfig{} // Configure TLS settings skipVerify := meta.SkipVerify tlsConf.vaultSkipVerify = false if skipVerify == "true" { tlsConf.vaultSkipVerify = true } tlsConf.vaultCACert = meta.CaCert tlsConf.vaultCAPem = meta.CaPem tlsConf.vaultCAPath = meta.CaPath tlsConf.vaultServerName = meta.TLSServerName return &tlsConf } // GetSecret retrieves a secret using a key and returns a map of decrypted string/string values. func (v *vaultSecretStore) getSecret(ctx context.Context, secret, version string) (*vaultKVResponse, error) { // Create get secret url var vaultSecretPathAddr string if v.vaultKVPrefix == "" { vaultSecretPathAddr = v.vaultAddress + "/v1/" + v.vaultEnginePath + "/data/" + secret + "?version=" + version } else { vaultSecretPathAddr = v.vaultAddress + "/v1/" + v.vaultEnginePath + "/data/" + v.vaultKVPrefix + "/" + secret + "?version=" + version } httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, vaultSecretPathAddr, nil) if err != nil { return nil, fmt.Errorf("couldn't generate request: %w", err) } // Set vault token. httpReq.Header.Set(vaultHTTPHeader, v.vaultToken) // Set X-Vault-Request header httpReq.Header.Set(vaultHTTPRequestHeader, "true") httpresp, err := v.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("couldn't get secret: %w", err) } defer httpresp.Body.Close() if httpresp.StatusCode != http.StatusOK { var b bytes.Buffer io.Copy(&b, httpresp.Body) v.logger.Debugf("getSecret %s couldn't get successful response: %#v, %s", secret, httpresp, b.String()) if httpresp.StatusCode == http.StatusNotFound { // handle not found error return nil, fmt.Errorf("getSecret %s failed %w", secret, ErrNotFound) } return nil, fmt.Errorf("couldn't get successful response, status code %d, body %s", httpresp.StatusCode, b.String()) } var d vaultKVResponse if v.vaultValueType.isMapType() { // parse the secret value to map[string]string if err := json.NewDecoder(httpresp.Body).Decode(&d); err != nil { return nil, fmt.Errorf("couldn't decode response body: %s", err) } } else { // treat the secret as string b, err := io.ReadAll(httpresp.Body) if err != nil { return nil, fmt.Errorf("couldn't read response: %s", err) } res := v.json.Get(b, DataStr, DataStr).ToString() d.Data.Data = map[string]string{ secret: res, } } return &d, nil } // GetSecret retrieves a secret using a key and returns a map of decrypted string/string values. func (v *vaultSecretStore) GetSecret(ctx context.Context, req secretstores.GetSecretRequest) (secretstores.GetSecretResponse, error) { // version 0 represent for latest version version := "0" if value, ok := req.Metadata[versionID]; ok { version = value } d, err := v.getSecret(ctx, req.Name, version) if err != nil { return secretstores.GetSecretResponse{Data: nil}, err } resp := secretstores.GetSecretResponse{ Data: d.Data.Data, } return resp, nil } // BulkGetSecret retrieves all secrets in the store and returns a map of decrypted string/string values. func (v *vaultSecretStore) BulkGetSecret(ctx context.Context, req secretstores.BulkGetSecretRequest) (secretstores.BulkGetSecretResponse, error) { version := "0" if value, ok := req.Metadata[versionID]; ok { version = value } resp := secretstores.BulkGetSecretResponse{ Data: map[string]map[string]string{}, } keys, err := v.listKeysUnderPath(ctx, "") if err != nil { return secretstores.BulkGetSecretResponse{}, err } for _, key := range keys { keyValues := map[string]string{} secrets, err := v.getSecret(ctx, key, version) if err != nil { if errors.Is(err, ErrNotFound) { // version not exist skip continue } return secretstores.BulkGetSecretResponse{Data: nil}, err } for k, v := range secrets.Data.Data { keyValues[k] = v } resp.Data[key] = keyValues } return resp, nil } // listKeysUnderPath get all the keys recursively under a given path.(returned keys including path as prefix) // path should not has `/` prefix. func (v *vaultSecretStore) listKeysUnderPath(ctx context.Context, path string) ([]string, error) { var vaultSecretsPathAddr string // Create list secrets url if v.vaultKVPrefix == "" { vaultSecretsPathAddr = fmt.Sprintf("%s/v1/%s/metadata/%s", v.vaultAddress, v.vaultEnginePath, path) } else { vaultSecretsPathAddr = fmt.Sprintf("%s/v1/%s/metadata/%s/%s", v.vaultAddress, v.vaultEnginePath, v.vaultKVPrefix, path) } httpReq, err := http.NewRequestWithContext(ctx, "LIST", vaultSecretsPathAddr, nil) if err != nil { return nil, fmt.Errorf("couldn't generate request: %s", err) } // Set vault token. httpReq.Header.Set(vaultHTTPHeader, v.vaultToken) // Set X-Vault-Request header httpReq.Header.Set(vaultHTTPRequestHeader, "true") httpresp, err := v.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("couldn't get secret: %s", err) } defer httpresp.Body.Close() if httpresp.StatusCode != http.StatusOK { var b bytes.Buffer io.Copy(&b, httpresp.Body) v.logger.Debugf("list keys couldn't get successful response: %#v, %s", httpresp, b.String()) return nil, fmt.Errorf("list keys couldn't get successful response, status code: %d, status: %s, response %s", httpresp.StatusCode, httpresp.Status, b.String()) } var d vaultListKVResponse if err := json.NewDecoder(httpresp.Body).Decode(&d); err != nil { return nil, fmt.Errorf("couldn't decode response body: %s", err) } res := make([]string, 0, len(d.Data.Keys)) for _, key := range d.Data.Keys { if v.isSecretPath(key) { res = append(res, path+key) } else { subKeys, err := v.listKeysUnderPath(ctx, path+key) if err != nil { return nil, err } res = append(res, subKeys...) } } return res, nil } // isSecretPath checks if the key is a valid secret path or it is part of the secret path. func (v *vaultSecretStore) isSecretPath(key string) bool { return !strings.HasSuffix(key, "/") } // initVaultToken reads the vault token from the file if token is defined by mount path. func (v *vaultSecretStore) initVaultToken() error { // Test that at least one of them are set if not return error if v.vaultToken == "" && v.vaultTokenMountPath == "" { return fmt.Errorf("token mount path and token not set") } // Test that both are not set. If so return error if v.vaultToken != "" && v.vaultTokenMountPath != "" { return fmt.Errorf("token mount path and token both set") } if v.vaultToken != "" { return nil } data, err := os.ReadFile(v.vaultTokenMountPath) if err != nil { return fmt.Errorf("couldn't read vault token from mount path %s err: %s", v.vaultTokenMountPath, err) } v.vaultToken = string(bytes.TrimSpace(data)) return nil } func (v *vaultSecretStore) createHTTPClient(config *tlsConfig) (*http.Client, error) { tlsClientConfig := &tls.Config{MinVersion: tls.VersionTLS12} if config != nil && config.vaultSkipVerify { v.logger.Infof("hashicorp vault: you are using 'skipVerify' to skip server config verify which is unsafe!") } tlsClientConfig.InsecureSkipVerify = config.vaultSkipVerify if !config.vaultSkipVerify { rootCAPools, err := v.getRootCAsPools(config.vaultCAPem, config.vaultCAPath, config.vaultCACert) if err != nil { return nil, err } tlsClientConfig.RootCAs = rootCAPools if config.vaultServerName != "" { tlsClientConfig.ServerName = config.vaultServerName } } // Setup http transport transport := &http.Transport{ TLSClientConfig: tlsClientConfig, } // Configure http2 client err := http2.ConfigureTransport(transport) if err != nil { return nil, errors.New("failed to configure http2") } return &http.Client{ Transport: transport, }, nil } // getRootCAsPools returns root CAs when you give it CA Pem file, CA path, and CA Certificate. Default is system certificates. func (v *vaultSecretStore) getRootCAsPools(vaultCAPem string, vaultCAPath string, vaultCACert string) (*x509.CertPool, error) { if vaultCAPem != "" { certPool := x509.NewCertPool() cert := []byte(vaultCAPem) if ok := certPool.AppendCertsFromPEM(cert); !ok { return nil, fmt.Errorf("couldn't read PEM") } return certPool, nil } if vaultCAPath != "" { certPool := x509.NewCertPool() if err := readCertificateFolder(certPool, vaultCAPath); err != nil { return nil, err } return certPool, nil } if vaultCACert != "" { certPool := x509.NewCertPool() if err := readCertificateFile(certPool, vaultCACert); err != nil { return nil, err } return certPool, nil } certPool, err := x509.SystemCertPool() if err != nil { return nil, fmt.Errorf("couldn't read system certs: %s", err) } return certPool, nil } // readCertificateFile reads the certificate at given path. func readCertificateFile(certPool *x509.CertPool, path string) error { // Read certificate file pemFile, err := os.ReadFile(path) if err != nil { return fmt.Errorf("couldn't read CA file from disk: %s", err) } if ok := certPool.AppendCertsFromPEM(pemFile); !ok { return fmt.Errorf("couldn't read PEM") } return nil } // readCertificateFolder scans a folder for certificates. func readCertificateFolder(certPool *x509.CertPool, path string) error { err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error { if info.IsDir() { return nil } return readCertificateFile(certPool, p) }) if err != nil { return fmt.Errorf("couldn't read certificates at %s: %s", path, err) } return nil } // Features returns the features available in this secret store. func (v *vaultSecretStore) Features() []secretstores.Feature { if v.vaultValueType == valueTypeText { return []secretstores.Feature{} } return []secretstores.Feature{secretstores.FeatureMultipleKeyValuesPerSecret} } func (v *vaultSecretStore) GetComponentMetadata() (metadataInfo metadata.MetadataMap) { metadataStruct := VaultMetadata{} metadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, metadata.SecretStoreType) return }