From 31f1faa9b2081405403444e87ff1c26ee6da2e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=C3=A4rchy?= Date: Fri, 23 May 2025 09:26:43 +0200 Subject: [PATCH] vault store: fully support vaulth paths Vault requires the /data path segment for kvv2, before the secret name. --- README.md | 2 + stores/vault/vault.go | 84 ++++++++++++++++++---------------- stores/vault/vault_test.go | 93 ++++++++++++++++++++++++-------------- 3 files changed, 107 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index fde8c47..376e7de 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,8 @@ Annotations: cert-manager-sync.lestak.sh/vault-role: "role-name" # HashiCorp Vault role name cert-manager-sync.lestak.sh/vault-auth-method: "auth-method" # HashiCorp Vault auth method name cert-manager-sync.lestak.sh/vault-path: "kv-name/path/to/secret" # HashiCorp Vault path to store cert + cert-manager-sync.lestak.sh/vault-secret-name: "secret-name" # The name to store + cert-manager-sync.lestak.sh/vault-version: "kvv2" # Hashicorp Vault KV version. "kvv1" or "kvv2". Default is "kvv2" cert-manager-sync.lestak.sh/vault-base64-decode: "true" # base64 decode the cert before storing in Vault. Default is "false" cert-manager-sync.lestak.sh/vault-pkcs12: "true" # convert the cert to PKCS#12 format before storing in Vault. Default is "false" cert-manager-sync.lestak.sh/vault-pkcs12-password-secret: "secret-name" # name of the secret containing the password (if not specified, a random password will be generated and stored in Vault) diff --git a/stores/vault/vault.go b/stores/vault/vault.go index d8053ff..9ab4623 100644 --- a/stores/vault/vault.go +++ b/stores/vault/vault.go @@ -8,15 +8,14 @@ import ( "encoding/pem" "errors" "fmt" - "os" - "strings" - "github.com/hashicorp/vault/api" "github.com/robertlestak/cert-manager-sync/pkg/state" "github.com/robertlestak/cert-manager-sync/pkg/tlssecret" log "github.com/sirupsen/logrus" - "software.sslmate.com/src/go-pkcs12" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "os" + "path/filepath" + "software.sslmate.com/src/go-pkcs12" ) type VaultStore struct { @@ -25,6 +24,8 @@ type VaultStore struct { Role string AuthMethod string Path string + SecretName string + KvVersion string Base64Decode bool PKCS12 bool PKCS12PassSecret string // Name of the secret containing the password @@ -139,27 +140,24 @@ func (s *VaultStore) WriteSecret(sec map[string]interface{}) (map[string]interfa }) l.Debugf("vault.WriteSecret") var secrets map[string]interface{} - pp := strings.Split(s.Path, "/") - if len(pp) < 2 { - return secrets, errors.New("secret path must be in kv/path/to/secret format") - } - pp = insertSliceString(pp, 1, "data") - if len(pp) == 0 { + + if s.Path == "" { return secrets, errors.New("secret path required") } - if pp == nil { - s.Path = "/" - } else { - s.Path = strings.Join(pp, "/") + if s.SecretName == "" { + return secrets, errors.New("secret name required") } - l.Debugf("vault.WriteSecret writing to %s", s.Path) - if s.Path == "" { - return secrets, errors.New("secret path required") + + fullPath := s.Path + if s.KvVersion == "kvv2" { + fullPath = filepath.Join(fullPath, "data") } + fullPath = filepath.Join(fullPath, s.SecretName) + vd := map[string]interface{}{ "data": sec, } - _, err := s.Client.Logical().Write(s.Path, vd) + _, err := s.Client.Logical().Write(fullPath, vd) if err != nil { l.WithError(err).Errorf("vault.WriteSecret error") return secrets, err @@ -175,6 +173,14 @@ func (s *VaultStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error { if c.Config["path"] != "" { s.Path = c.Config["path"] } + if c.Config["version"] != "" { + s.KvVersion = c.Config["version"] + } else { + s.KvVersion = "kvv2" + } + if c.Config["secret-name"] != "" { + s.SecretName = c.Config["secret-name"] + } if c.Config["addr"] != "" { s.Addr = c.Config["addr"] } @@ -222,37 +228,37 @@ func writeSecretValue(value []byte, asString bool) any { // getPasswordFromSecret retrieves the PKCS#12 password from a Kubernetes secret func (s *VaultStore) getPasswordFromSecret(c *tlssecret.Certificate) (string, error) { l := log.WithFields(log.Fields{ - "action": "getPasswordFromSecret", - "secret": s.PKCS12PassSecret, + "action": "getPasswordFromSecret", + "secret": s.PKCS12PassSecret, "namespace": s.PKCS12PassSecretNamespace, }) l.Debug("Retrieving PKCS#12 password from secret") - + // If no secret is specified, return empty string if s.PKCS12PassSecret == "" { return "", nil } - + // Get the secret from Kubernetes secret, err := state.KubeClient.CoreV1().Secrets(s.PKCS12PassSecretNamespace).Get( - context.Background(), - s.PKCS12PassSecret, + context.Background(), + s.PKCS12PassSecret, metav1.GetOptions{}, ) if err != nil { l.WithError(err).Error("Failed to get secret containing PKCS#12 password") return "", err } - + // Get the password from the secret passwordBytes, ok := secret.Data[s.PKCS12PassSecretKey] if !ok { - err := fmt.Errorf("key %s not found in secret %s/%s", + err := fmt.Errorf("key %s not found in secret %s/%s", s.PKCS12PassSecretKey, s.PKCS12PassSecretNamespace, s.PKCS12PassSecret) l.WithError(err).Error("Failed to get PKCS#12 password from secret") return "", err } - + return string(passwordBytes), nil } @@ -279,10 +285,10 @@ func (s *VaultStore) convertToPKCS12WithPassword(cert []byte, key []byte, ca []b if keyBlock == nil { return nil, "", fmt.Errorf("failed to decode key PEM") } - + var privateKey interface{} var parseErr error - + // Try different key formats if keyBlock.Type == "EC PRIVATE KEY" { privateKey, parseErr = x509.ParseECPrivateKey(keyBlock.Bytes) @@ -292,7 +298,7 @@ func (s *VaultStore) convertToPKCS12WithPassword(cert []byte, key []byte, ca []b // Try PKCS8 as a fallback privateKey, parseErr = x509.ParsePKCS8PrivateKey(keyBlock.Bytes) } - + if parseErr != nil { return nil, "", fmt.Errorf("failed to parse private key: %v", parseErr) } @@ -315,7 +321,7 @@ func (s *VaultStore) convertToPKCS12WithPassword(cert []byte, key []byte, ca []b caCerts = append(caCerts, caCert) } } - + // If no password provided, generate a random one if password == "" { // Generate a random password @@ -348,7 +354,7 @@ func (s *VaultStore) convertToPKCS12(cert []byte, key []byte, ca []byte, c *tlss l.WithError(err).Error("Failed to get password from secret") return nil, "", fmt.Errorf("failed to get password from secret: %v", err) } - + // Convert to PKCS#12 with the password (or generate a random one if empty) return s.convertToPKCS12WithPassword(cert, key, ca, password) } @@ -375,13 +381,13 @@ func (s *VaultStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { "vaultAuthMethod": s.AuthMethod, "id": vid, }) - + // If PKCS12 is enabled and we need to use the certificate namespace for the password secret if s.PKCS12 && s.PKCS12PassSecret != "" && s.PKCS12PassSecretNamespace == "" { // Set the namespace to the certificate namespace s.PKCS12PassSecretNamespace = c.Namespace } - + _, cerr := s.NewClient() if cerr != nil { l.WithError(cerr).Errorf("vault.NewClient error") @@ -392,16 +398,16 @@ func (s *VaultStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { l.WithError(err).Errorf("vault.NewToken error") return nil, err } - + cd := map[string]interface{}{} - + // Always store the original PEM files cd["tls.crt"] = writeSecretValue(c.Certificate, s.Base64Decode) cd["tls.key"] = writeSecretValue(c.Key, s.Base64Decode) if len(c.Ca) > 0 { cd["ca.crt"] = writeSecretValue(c.Ca, s.Base64Decode) } - + // If PKCS#12 is enabled, convert and store the certificate in PKCS#12 format if s.PKCS12 { l.Debug("Converting certificate to PKCS#12 format") @@ -410,9 +416,9 @@ func (s *VaultStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { l.WithError(err).Errorf("PKCS#12 conversion error") return nil, err } - + cd["pkcs12"] = writeSecretValue(pkcs12Data, s.Base64Decode) - + // Store the password if it was generated (not provided in secret) if s.PKCS12PassSecret == "" { cd["pkcs12-password"] = password diff --git a/stores/vault/vault_test.go b/stores/vault/vault_test.go index 7f01d93..fa4e6b2 100644 --- a/stores/vault/vault_test.go +++ b/stores/vault/vault_test.go @@ -255,7 +255,7 @@ func TestPKCS12Conversion(t *testing.T) { if len(caCerts) == 0 { t.Error("No CA certificates in PKCS12 data") } - + // Verify the private key type _, ok := privateKey.(*ecdsa.PrivateKey) if !ok { @@ -298,7 +298,7 @@ func TestPKCS12Conversion(t *testing.T) { } }) }) - + // Test with RSA certificate t.Run("RSA Certificate", func(t *testing.T) { // Generate test certificate and key @@ -345,7 +345,7 @@ func TestPKCS12Conversion(t *testing.T) { if len(caCerts) == 0 { t.Error("No CA certificates in PKCS12 data") } - + // Verify the private key type _, ok := privateKey.(*rsa.PrivateKey) if !ok { @@ -353,7 +353,7 @@ func TestPKCS12Conversion(t *testing.T) { } }) }) - + // Test with certificate but no CA t.Run("Certificate without CA", func(t *testing.T) { // Generate test certificate and key @@ -406,40 +406,54 @@ func TestFromConfig(t *testing.T) { { name: "Basic config", config: map[string]string{ - "path": "secret/data/test", + "path": "kvv/team-1", + "secret-name": "test", "addr": "https://vault.example.com", "namespace": "ns1", "role": "role1", "auth-method": "kubernetes", }, want: VaultStore{ - Path: "secret/data/test", - Addr: "https://vault.example.com", - Namespace: "ns1", - Role: "role1", + Path: "kvv/team-1", + SecretName: "test", + Addr: "https://vault.example.com", + Namespace: "ns1", + Role: "role1", AuthMethod: "kubernetes", + KvVersion: "kvv2", + }, + }, + { + name: "With kvv1 version", + config: map[string]string{ + "version": "kvv1", + }, + want: VaultStore{ + KvVersion: "kvv1", }, }, { name: "With base64 decode", config: map[string]string{ - "path": "secret/data/test", + "path": "secret/data/test", "base64-decode": "true", }, want: VaultStore{ - Path: "secret/data/test", + Path: "secret/data/test", Base64Decode: true, + KvVersion: "kvv2", }, }, { name: "With PKCS12 enabled", config: map[string]string{ - "path": "secret/data/test", - "pkcs12": "true", + "path": "secret/data/test", + "pkcs12": "true", }, want: VaultStore{ - Path: "secret/data/test", - PKCS12: true, + Path: "secret/data/test", + PKCS12: true, + KvVersion: "kvv2", }, }, { @@ -450,10 +464,11 @@ func TestFromConfig(t *testing.T) { "pkcs12-password-secret": "my-secret", }, want: VaultStore{ - Path: "secret/data/test", - PKCS12: true, - PKCS12PassSecret: "my-secret", + Path: "secret/data/test", + PKCS12: true, + PKCS12PassSecret: "my-secret", PKCS12PassSecretKey: "password", // Default value + KvVersion: "kvv2", }, }, { @@ -469,14 +484,15 @@ func TestFromConfig(t *testing.T) { PKCS12: true, PKCS12PassSecret: "my-secret", PKCS12PassSecretKey: "my-key", + KvVersion: "kvv2", }, }, { name: "With PKCS12 password secret and namespace", config: map[string]string{ - "path": "secret/data/test", - "pkcs12": "true", - "pkcs12-password-secret": "my-secret", + "path": "secret/data/test", + "pkcs12": "true", + "pkcs12-password-secret": "my-secret", "pkcs12-password-secret-namespace": "my-namespace", }, want: VaultStore{ @@ -485,6 +501,7 @@ func TestFromConfig(t *testing.T) { PKCS12PassSecret: "my-secret", PKCS12PassSecretKey: "password", // Default value PKCS12PassSecretNamespace: "my-namespace", + KvVersion: "kvv2", }, }, } @@ -499,52 +516,62 @@ func TestFromConfig(t *testing.T) { if err != nil { t.Fatalf("FromConfig() error = %v", err) } - + // Check Path if store.Path != tt.want.Path { t.Errorf("Path = %v, want %v", store.Path, tt.want.Path) } - + + // Check SecretName + if store.SecretName != tt.want.SecretName { + t.Errorf("SecretName = %v, want %v", store.SecretName, tt.want.SecretName) + } + + // Check KvVersion + if store.KvVersion != tt.want.KvVersion { + t.Errorf("KvVersion = %v, want %v", store.KvVersion, tt.want.KvVersion) + } + // Check Addr if store.Addr != tt.want.Addr { t.Errorf("Addr = %v, want %v", store.Addr, tt.want.Addr) } - + // Check Namespace if store.Namespace != tt.want.Namespace { t.Errorf("Namespace = %v, want %v", store.Namespace, tt.want.Namespace) } - + // Check Role if store.Role != tt.want.Role { t.Errorf("Role = %v, want %v", store.Role, tt.want.Role) } - + // Check AuthMethod if store.AuthMethod != tt.want.AuthMethod { t.Errorf("AuthMethod = %v, want %v", store.AuthMethod, tt.want.AuthMethod) } - + // Check Base64Decode if store.Base64Decode != tt.want.Base64Decode { t.Errorf("Base64Decode = %v, want %v", store.Base64Decode, tt.want.Base64Decode) } - + // Check PKCS12 if store.PKCS12 != tt.want.PKCS12 { t.Errorf("PKCS12 = %v, want %v", store.PKCS12, tt.want.PKCS12) } - + // Check PKCS12PassSecret if store.PKCS12PassSecret != tt.want.PKCS12PassSecret { t.Errorf("PKCS12PassSecret = %v, want %v", store.PKCS12PassSecret, tt.want.PKCS12PassSecret) } - + // Check PKCS12PassSecretKey if store.PKCS12PassSecretKey != tt.want.PKCS12PassSecretKey { t.Errorf("PKCS12PassSecretKey = %v, want %v", store.PKCS12PassSecretKey, tt.want.PKCS12PassSecretKey) } - + // Check PKCS12PassSecretNamespace if store.PKCS12PassSecretNamespace != tt.want.PKCS12PassSecretNamespace { t.Errorf("PKCS12PassSecretNamespace = %v, want %v", store.PKCS12PassSecretNamespace, tt.want.PKCS12PassSecretNamespace) @@ -579,11 +606,11 @@ func TestWriteSecretValue(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := writeSecretValue(tt.value, tt.asString) gotType := reflect.TypeOf(got).String() - + if gotType != tt.wantType { t.Errorf("writeSecretValue() type = %v, want %v", gotType, tt.wantType) } - + // Check value if tt.asString { if got.(string) != string(tt.value) {