diff --git a/edge/server-config-yml.go b/edge/server-config-yml.go index eed3f154..e0c761c9 100644 --- a/edge/server-config-yml.go +++ b/edge/server-config-yml.go @@ -89,6 +89,11 @@ type yml struct { Namespace env[string] `yaml:"namespace"` Prefix env[string] `yaml:"prefix"` + Transit *struct { + Engine env[string] `yaml:"engine"` + KeyName env[string] `yaml:"key"` + } + AppRole *struct { Engine env[string] `yaml:"engine"` ID env[string] `yaml:"id"` @@ -448,6 +453,12 @@ func ymlToKeyStore(y *yml) (KeyStore, error) { y.KeyStore.Vault.Kubernetes.JWT.Value = string(b) } } + if y.KeyStore.Vault.Transit != nil { + if y.KeyStore.Vault.Transit.KeyName.Value == "" { + return nil, errors.New("edge: invalid vault keystore: invalid transit config: no key name specified") + } + } + if y.KeyStore.Vault.TLS.PrivateKey.Value != "" && y.KeyStore.Vault.TLS.Certificate.Value == "" { return nil, errors.New("edge: invalid vault keystore: invalid tls config: no TLS certificate provided") } @@ -479,6 +490,12 @@ func ymlToKeyStore(y *yml) (KeyStore, error) { Role: y.KeyStore.Vault.Kubernetes.Role.Value, } } + if y.KeyStore.Vault.Transit != nil { + s.Transit = &VaultTransit{ + Engine: y.KeyStore.Vault.Transit.Engine.Value, + KeyName: y.KeyStore.Vault.Transit.KeyName.Value, + } + } keystore = s } diff --git a/edge/server-config.go b/edge/server-config.go index fc8d3499..8cecabb9 100644 --- a/edge/server-config.go +++ b/edge/server-config.go @@ -324,6 +324,15 @@ type VaultKeyStore struct { // method credentials. Kubernetes *VaultKubernetesAuth + // Transit contains the Vault transit encryption engine + // configuration for en/decrypting K/V entries with a + // key managed by Vault. + // + // This is an optional and additional layer of encryption. + // Since Vault manages and encrypts K/V values in any case, + // using the transit engine is usually not necessary. + Transit *VaultTransit + // PrivateKey is an optional path to a // TLS private key file containing a // TLS private key for mTLS authentication. @@ -357,7 +366,7 @@ type VaultKeyStore struct { // VaultAppRoleAuth is a structure containing the configuration // for the Hashicorp Vault AppRole authentication method. type VaultAppRoleAuth struct { - // AppRoleEngine is the AppRole authentication engine path. + // Engine is the AppRole authentication engine path. // If empty, defaults to "approle". Engine string @@ -373,7 +382,7 @@ type VaultAppRoleAuth struct { // VaultKubernetesAuth is a structure containing the configuration // for the Hashicorp Vault Kubernetes authentication method. type VaultKubernetesAuth struct { - // KubernetesEngine is the Kubernetes authentication engine path. + // Engine is the Kubernetes authentication engine path. // If empty, defaults to "kubernetes". Engine string @@ -387,6 +396,17 @@ type VaultKubernetesAuth struct { JWT string } +// VaultTransit is a structure containing the configuration +// for the Hashicorp Vault transit encryption engine. +type VaultTransit struct { + // Engine is the Transit encryption engine path. + // If empty, defaults to "transit". + Engine string + + // KeyName is the name of the key used for en/decryption. + KeyName string +} + // Connect returns a kv.Store that stores key-value pairs on a Hashicorp Vault server. func (s *VaultKeyStore) Connect(ctx context.Context) (kv.Store[string, []byte], error) { if s.AppRole == nil && s.Kubernetes == nil { @@ -407,19 +427,25 @@ func (s *VaultKeyStore) Connect(ctx context.Context) (kv.Store[string, []byte], StatusPingAfter: s.StatusPing, } if s.AppRole != nil { - c.AppRole = vault.AppRole{ + c.AppRole = &vault.AppRole{ Engine: s.AppRole.Engine, ID: s.AppRole.ID, Secret: s.AppRole.Secret, } } if s.Kubernetes != nil { - c.K8S = vault.Kubernetes{ + c.K8S = &vault.Kubernetes{ Engine: s.Kubernetes.Engine, Role: s.Kubernetes.Role, JWT: s.Kubernetes.JWT, } } + if s.Transit != nil { + c.Transit = &vault.Transit{ + Engine: s.Transit.Engine, + KeyName: s.Transit.KeyName, + } + } return vault.Connect(ctx, c) } diff --git a/internal/keystore/vault/client.go b/internal/keystore/vault/client.go index aa4fe3d1..469a59db 100644 --- a/internal/keystore/vault/client.go +++ b/internal/keystore/vault/client.go @@ -69,7 +69,7 @@ func (c *client) CheckStatus(ctx context.Context, delay time.Duration) { // from the vault server by using the login AppRole credentials. // // To renew the auth. token see: client.RenewToken(...). -func (c *client) AuthenticateWithAppRole(login AppRole) authFunc { +func (c *client) AuthenticateWithAppRole(login *AppRole) authFunc { return func() (token string, ttl time.Duration, err error) { secret, err := c.Logical().Write(path.Join("auth", login.Engine, "login"), map[string]interface{}{ "role_id": login.ID, @@ -99,7 +99,7 @@ func (c *client) AuthenticateWithAppRole(login AppRole) authFunc { } } -func (c *client) AuthenticateWithK8S(login Kubernetes) authFunc { +func (c *client) AuthenticateWithK8S(login *Kubernetes) authFunc { return func() (token string, ttl time.Duration, err error) { secret, err := c.Logical().Write(path.Join("auth", login.Engine, "login"), map[string]interface{}{ "role": login.Role, diff --git a/internal/keystore/vault/config.go b/internal/keystore/vault/config.go index bdf706ea..4ddb0465 100644 --- a/internal/keystore/vault/config.go +++ b/internal/keystore/vault/config.go @@ -24,6 +24,9 @@ const ( // EngineKV is the Hashicorp Vault default KV secret engine path. EngineKV = "kv" + // EngineTransit is the Hashicorp Vault default transit secret engine path. + EngineTransit = "transit" + // EngineAppRole is the Hashicorp Vault default AppRole authentication // engine path. EngineAppRole = "approle" @@ -58,6 +61,19 @@ type AppRole struct { Retry time.Duration } +// Clone returns a copy of the AppRole auth. +func (a *AppRole) Clone() *AppRole { + if a == nil { + return nil + } + return &AppRole{ + Engine: a.Engine, + ID: a.ID, + Secret: a.Secret, + Retry: a.Retry, + } +} + // Kubernetes contains authentication information // for the Hashicorp Vault Kubernetes authentication // API. @@ -83,6 +99,44 @@ type Kubernetes struct { Retry time.Duration } +// Clone returns a copy of the Kubernetes auth. +func (k *Kubernetes) Clone() *Kubernetes { + if k == nil { + return nil + } + return &Kubernetes{ + Engine: k.Engine, + Role: k.Role, + JWT: k.JWT, + Retry: k.Retry, + } +} + +// Transit contains information for using the +// Hashicorp Vault transit encryption engine. +// +// Ref: https://developer.hashicorp.com/vault/api-docs/secret/transit +type Transit struct { + // Engine is the transit engine path. + // If empty, defaults to EngineTransit. + Engine string + + // KeyName is the name of the transit key + // used for en/decrypting K/V entries. + KeyName string +} + +// Clone returns a copy of the Transit. +func (t *Transit) Clone() *Transit { + if t == nil { + return nil + } + return &Transit{ + Engine: t.Engine, + KeyName: t.KeyName, + } +} + // Config is a structure containing configuration // options for connecting to a Hashicorp Vault server. type Config struct { @@ -119,11 +173,16 @@ type Config struct { // AppRole contains the Vault AppRole authentication // credentials. - AppRole AppRole + AppRole *AppRole // K8S contains the Vault Kubernetes authentication // credentials. - K8S Kubernetes + K8S *Kubernetes + + // Transit contains an optional Vault transit engine + // configuration for en/decrypting keys at the K/V + // engine. It adds an additional layer of encryption. + Transit *Transit // StatusPingAfter is the duration after which // the KeyStore will check the status of the Vault @@ -164,8 +223,9 @@ func (c *Config) Clone() *Config { APIVersion: c.APIVersion, Namespace: c.Namespace, Prefix: c.Prefix, - AppRole: c.AppRole, - K8S: c.K8S, + AppRole: c.AppRole.Clone(), + K8S: c.K8S.Clone(), + Transit: c.Transit.Clone(), StatusPingAfter: c.StatusPingAfter, PrivateKey: c.PrivateKey, Certificate: c.Certificate, diff --git a/internal/keystore/vault/config_test.go b/internal/keystore/vault/config_test.go index 1cf56c24..83b4b2ba 100644 --- a/internal/keystore/vault/config_test.go +++ b/internal/keystore/vault/config_test.go @@ -5,13 +5,14 @@ package vault import ( + "reflect" "testing" "time" ) func TestCloneConfig(t *testing.T) { for i, a := range cloneConfigTests { - if b := a.Clone(); *a != *b { + if b := a.Clone(); !reflect.DeepEqual(a, b) { t.Fatalf("Test %d: cloned config does not match original", i) } } @@ -24,17 +25,21 @@ var cloneConfigTests = []*Config{ APIVersion: APIv2, Namespace: "ns-1", Prefix: "my-prefix", - AppRole: AppRole{ + AppRole: &AppRole{ Engine: "auth", ID: "be7f3c83-9733-4d65-adaa-7eeb6e14e922", Secret: "ba8d68af-23c4-4199-a516-e37cebdaab48", Retry: 30 * time.Second, }, - K8S: Kubernetes{ + K8S: &Kubernetes{ Engine: "auth", Role: "kes", JWT: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", }, + Transit: &Transit{ + Engine: "transit", + KeyName: "my-key", + }, StatusPingAfter: 15 * time.Second, PrivateKey: "/tmp/kes/vault.key", Certificate: "/tmp/kes/vault.crt", diff --git a/internal/keystore/vault/vault.go b/internal/keystore/vault/vault.go index 6ebc9c32..0b9907b2 100644 --- a/internal/keystore/vault/vault.go +++ b/internal/keystore/vault/vault.go @@ -14,11 +14,13 @@ package vault import ( "context" + "encoding/base64" "errors" "fmt" "net/http" "os" "path" + "strings" "time" "aead.dev/mem" @@ -45,17 +47,26 @@ func Connect(ctx context.Context, c *Config) (*Store, error) { if c.APIVersion == "" { c.APIVersion = APIv1 } - if c.AppRole.Retry == 0 { - c.AppRole.Retry = 5 * time.Second - } - if c.AppRole.Engine == "" { - c.AppRole.Engine = EngineAppRole + if c.AppRole != nil { + if c.AppRole.Retry == 0 { + c.AppRole.Retry = 5 * time.Second + } + if c.AppRole.Engine == "" { + c.AppRole.Engine = EngineAppRole + } } - if c.K8S.Engine == "" { - c.K8S.Engine = EngineKubernetes + if c.K8S != nil { + if c.K8S.Engine == "" { + c.K8S.Engine = EngineKubernetes + } + if c.K8S.Retry == 0 { + c.K8S.Retry = 5 * time.Second + } } - if c.K8S.Retry == 0 { - c.K8S.Retry = 5 * time.Second + if c.Transit != nil { + if c.Transit.Engine == "" { + c.Transit.Engine = EngineTransit + } } if c.StatusPingAfter == 0 { c.StatusPingAfter = 15 * time.Second @@ -67,11 +78,18 @@ func Connect(ctx context.Context, c *Config) (*Store, error) { if c.APIVersion != APIv1 && c.APIVersion != APIv2 { return nil, fmt.Errorf("vault: invalid engine API version '%s'", c.APIVersion) } - if (c.AppRole.ID == "" || c.AppRole.Secret == "") && (c.K8S.JWT == "" || c.K8S.Role == "") { - return nil, errors.New("vault: no authentication method specified") + if c.AppRole != nil && c.K8S != nil { + if (c.AppRole.ID == "" || c.AppRole.Secret == "") && (c.K8S.JWT == "" || c.K8S.Role == "") { + return nil, errors.New("vault: no authentication method specified") + } + if (c.AppRole.ID != "" || c.AppRole.Secret != "") && (c.K8S.JWT != "" || c.K8S.Role != "") { + return nil, errors.New("vault: more than one authentication method specified: approle and kubernetes configuration is present") + } } - if (c.AppRole.ID != "" || c.AppRole.Secret != "") && (c.K8S.JWT != "" || c.K8S.Role != "") { - return nil, errors.New("vault: ambigious authentication: approle and kubernetes method specified") + if c.Transit != nil { + if c.Transit.KeyName == "" { + return nil, errors.New("vault: transit key name is empty") + } } tlsConfig := &vaultapi.TLSConfig{ @@ -236,6 +254,44 @@ func (s *Store) Create(ctx context.Context, name string, value []byte) error { return fmt.Errorf("vault: failed to create '%s': %v", location, err) } + if s.config.Transit != nil { + encLocation := path.Join(s.config.Transit.Engine, "encrypt", s.config.Transit.KeyName) + req := s.client.Client.NewRequest(http.MethodPost, "/v1/"+encLocation) + if err := req.SetJSONBody(map[string]any{ + "plaintext": base64.StdEncoding.EncodeToString(value), + }); err != nil { + return fmt.Errorf("vault: failed to create '%s': failed to encrypt key: %v", location, err) + } + + resp, err := s.client.Client.RawRequestWithContext(ctx, req) + if err != nil { + return fmt.Errorf("vault: failed to create '%s': failed to encrypt key: %v", location, err) + } + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if resp.StatusCode != http.StatusOK { + if _, err = vaultapi.ParseSecret(resp.Body); err != nil { + return fmt.Errorf("vault: failed to create '%s': failed to encrypt key: %v", location, err) + } + return fmt.Errorf("vault: failed to create '%s': server responded with: %s (%d)", location, resp.Status, resp.StatusCode) + } + + secret, err := vaultapi.ParseSecret(resp.Body) + if err != nil { + return fmt.Errorf("vault: failed to create '%s': failed to encrypt key: %v", location, err) + } + ciphertext, ok := secret.Data["ciphertext"] + if !ok { + return fmt.Errorf("vault: failed to create '%s': failed to encrypt key: no ciphertext in vault response", location) + } + v, ok := ciphertext.(string) + if !ok || !strings.HasPrefix(v, "vault:v1:") { + return fmt.Errorf("vault: failed to create '%s': failed to encrypt key: invalid vault response", location) + } + value = []byte(v) + } + // Finally, we create the value since it seems that it // doesn't exist. However, this is just an assumption since // another key server may have created that key in the meantime. @@ -297,7 +353,7 @@ func (s *Store) Set(ctx context.Context, name string, value []byte) error { // Get returns the value associated with the given key. // If no entry for the key exists it returns kes.ErrKeyNotFound. -func (s *Store) Get(_ context.Context, name string) ([]byte, error) { +func (s *Store) Get(ctx context.Context, name string) ([]byte, error) { if s.client.Sealed() { return nil, errSealed } @@ -341,6 +397,49 @@ func (s *Store) Get(_ context.Context, name string) ([]byte, error) { if !ok { return nil, fmt.Errorf("vault: failed to read '%s': invalid K/V format", location) } + + // Handle transit encrypted K/V entries + if strings.HasPrefix(value, "vault:v1:") { + if s.config.Transit == nil { + return nil, fmt.Errorf("vault: failed to read '%s': key is encrypted with vault transit key", location) + } + + decLocation := path.Join(s.config.Transit.Engine, "decrypt", s.config.Transit.KeyName) + req := s.client.Client.NewRequest(http.MethodPost, "/v1/"+decLocation) + if err := req.SetJSONBody(map[string]any{ + "ciphertext": value, + }); err != nil { + return nil, fmt.Errorf("vault: failed to read '%s': failed to decrypt key: %v", location, err) + } + + resp, err := s.client.Client.RawRequestWithContext(ctx, req) + if err != nil { + return nil, fmt.Errorf("vault: failed to read '%s': failed to decrypt key: %v", location, err) + } + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if resp.StatusCode != http.StatusOK { + if _, err = vaultapi.ParseSecret(resp.Body); err != nil { + return nil, fmt.Errorf("vault: failed to read '%s': failed to encrypt key: %v", location, err) + } + return nil, fmt.Errorf("vault: failed to read '%s': server responded with: %s (%d)", location, resp.Status, resp.StatusCode) + } + + secret, err := vaultapi.ParseSecret(resp.Body) + if err != nil { + return nil, fmt.Errorf("vault: failed to read '%s': failed to decrypt key: %v", location, err) + } + plaintext, ok := secret.Data["plaintext"] + if !ok { + return nil, fmt.Errorf("vault: failed to read '%s': failed to decrypt key: no plaintext in vault response", location) + } + value, ok = plaintext.(string) + if !ok { + return nil, fmt.Errorf("vault: failed to read '%s': failed to decrypt key: invalid vault response", location) + } + return base64.StdEncoding.DecodeString(value) + } return []byte(value), nil } diff --git a/server-config.yaml b/server-config.yaml index 0ac5f813..574f0ce7 100644 --- a/server-config.yaml +++ b/server-config.yaml @@ -260,6 +260,9 @@ keystore: version: "" # The K/V engine version - either "v1" or "v2". The "v1" engine is recommended. namespace: "" # An optional Vault namespace. See: https://www.vaultproject.io/docs/enterprise/namespaces/index.html prefix: "" # An optional K/V prefix. The server will store keys under this prefix. + transit: # Optionally encrypt keys stored on the K/V engine with a Vault-managed key. + engine: "" # The path of the transit engine - e.g. "my-transit". If empty, defaults to: transit (Vault default) + key: "" # The key name that should be used to encrypt entries stored on the K/V engine. approle: # AppRole credentials. See: https://www.vaultproject.io/docs/auth/approle.html engine: "" # The path of the AppRole engine - e.g. authenticate. If empty, defaults to: approle. (Vault default) id: "" # Your AppRole Role ID