diff --git a/docs/backends.md b/docs/backends.md index d59e1d63..344844be 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -117,6 +117,16 @@ AVP_USERNAME: Your Username AVP_PASSWORD: Your Password ``` +##### Certificate Authentication +For Certificate Authentication, these are the required parameters: +``` +VAULT_ADDR: Your HashiCorp Vault Address +AVP_TYPE: vault +AVP_AUTH_TYPE: certificate +AVP_CERT: Your client certificate +AVP_KEY: Your client key +``` + ##### Examples ###### Path Annotation diff --git a/docs/config.md b/docs/config.md index 64b64494..f49572a7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -72,10 +72,10 @@ We support all the backend specific environment variables each backend's SDK wil We also support these AVP specific variables: | Name | Description | Notes | -| -------------------------- |-----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +|----------------------------|-----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | AVP_TYPE | The type of Vault backend | Supported values: `vault`, `ibmsecretsmanager`, `awssecretsmanager`, `gcpsecretmanager`, `yandexcloudlockbox` and `1passwordconnect` | | AVP_KV_VERSION | The vault secret engine | Supported values: `1` and `2` (defaults to 2). KV_VERSION will be ignored if the `avp.kubernetes.io/kv-version` annotation is present in a YAML resource. | -| AVP_AUTH_TYPE | The type of authentication | Supported values: vault: `approle, github, k8s, token`. Only honored for `AVP_TYPE` of `vault` | +| AVP_AUTH_TYPE | The type of authentication | Supported values: vault: `approle, github, k8s, token, certificate`. Only honored for `AVP_TYPE` of `vault` | | AVP_GITHUB_TOKEN | Github token | Required with `AUTH_TYPE` of `github` | | AVP_ROLE_ID | Vault AppRole Role_ID | Required with `AUTH_TYPE` of `approle` | | AVP_SECRET_ID | Vault AppRole Secret_ID | Required with `AUTH_TYPE` of `approle` | @@ -90,7 +90,8 @@ We also support these AVP specific variables: | AVP_YCL_KEY_ID | Yandex Cloud Lockbox service account Key ID | Required with `TYPE` of `yandexcloudlockbox` | | AVP_YCL_PRIVATE_KEY | Yandex Cloud Lockbox service account private key | Required with `TYPE` of `yandexcloudlockbox` | | AVP_PATH_VALIDATION | Regular Expression to validate the Vault path | Optional. Can be used for e.g. to prevent path traversals. | - +| AVP_CERT | Your Vault client certificate | Required with `AUTH_TYPE`of `certificate` | +| AVP_KEY | Your Vault client key | Required with `AUTH_TYPE`of `certificate` | ### Full List of Supported Annotation We support several different annotations that can be used inside a kubernetes resource. These annotations will override any corresponding configuration set via Environment Variable or Configuration File. diff --git a/go.mod b/go.mod index f8e3e837..c47aedef 100644 --- a/go.mod +++ b/go.mod @@ -114,6 +114,7 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fatih/color v1.16.0 // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gammazero/deque v0.2.1 // indirect @@ -183,6 +184,7 @@ require ( github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 // indirect github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 // indirect + github.com/hashicorp/go-secure-stdlib/nonceutil v0.1.0 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect github.com/hashicorp/go-secure-stdlib/password v0.1.1 // indirect github.com/hashicorp/go-secure-stdlib/plugincontainer v0.3.0 // indirect @@ -194,6 +196,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.1-vault-5 // indirect github.com/hashicorp/hcp-sdk-go v0.75.0 // indirect github.com/hashicorp/mdns v1.0.4 // indirect diff --git a/go.sum b/go.sum index 5ff5eb67..a50ec040 100644 --- a/go.sum +++ b/go.sum @@ -867,6 +867,8 @@ github.com/hashicorp/vault-testing-stepwise v0.1.4 h1:Lsv1KdpQyjhvmLgKeH65FG5MmY github.com/hashicorp/vault-testing-stepwise v0.1.4/go.mod h1:Ym1T/kMM2sT6qgCIIJ3an7uaSWCJ8O7ohsWB9UiB5tI= github.com/hashicorp/vault/api v1.12.0 h1:meCpJSesvzQyao8FCOgk2fGdoADAnbDu2WPJN1lDLJ4= github.com/hashicorp/vault/api v1.12.0/go.mod h1:si+lJCYO7oGkIoNPAN8j3azBLTn9SjMGS+jFaHd1Cck= +github.com/hashicorp/vault/api/auth/userpass v0.1.0 h1:C6OdAYczMbzd1Pe1LLf2SHDulxOq/iybWV3kbgV/PS4= +github.com/hashicorp/vault/api/auth/userpass v0.1.0/go.mod h1:0orUbtkEwbEPmaQ+wvfrOddGBimLJnuN8A/J0PNfBks= github.com/hashicorp/vault/sdk v0.12.0 h1:c2WeMWtF08zKQmrJya7paM4IVnsXIXF5UlhQTBdwZwQ= github.com/hashicorp/vault/sdk v0.12.0/go.mod h1:2kN1F5owc/Yh1OwL32GGnYrX9E3vFOIKA/cGJxCNQ30= github.com/hashicorp/vic v1.5.1-0.20190403131502-bbfe86ec9443 h1:O/pT5C1Q3mVXMyuqg7yuAWUg/jMZR1/0QTzTRdNR6Uw= diff --git a/pkg/auth/vault/certificate.go b/pkg/auth/vault/certificate.go new file mode 100644 index 00000000..f44ae919 --- /dev/null +++ b/pkg/auth/vault/certificate.go @@ -0,0 +1,100 @@ +package vault + +import ( + "fmt" + "github.com/argoproj-labs/argocd-vault-plugin/pkg/utils" + "github.com/hashicorp/vault/api" + "os" +) + +const ( + certificateMountPath = "auth/cert" +) + +// CertificateAuth is a struct for working with Vault that uses certificate authentication +type CertificateAuth struct { + Certificate string + Key string + MountPath string +} + +// NewCertificateAuth initalizes a new CertificateAuth with cert & key +func NewCertificateAuth(cert, key, mountPath string) *CertificateAuth { + certificateAuth := &CertificateAuth{ + Certificate: cert, + Key: key, + MountPath: certificateMountPath, + } + if mountPath != "" { + certificateAuth.MountPath = mountPath + } + + return certificateAuth +} + +// Authenticate authenticates with Vault using userpass and returns a token +func (a *CertificateAuth) Authenticate(vaultClient *api.Client) error { + err := utils.LoginWithCachedToken(vaultClient) + if err != nil { + utils.VerboseToStdErr("Hashicorp Vault cannot retrieve cached token: %v. Generating a new one", err) + } else { + return nil + } + + payload := map[string]interface{}{} + + tempCrt, err := os.CreateTemp("", "vault_cert") + if err != nil { + return err + } + if _, err := tempCrt.WriteString(a.Certificate); err != nil { + return err + } + defer os.Remove(tempCrt.Name()) + + tempKey, err := os.CreateTemp("", "vault_key") + if err != nil { + return err + } + + if _, err := tempKey.WriteString(a.Key); err != nil { + return err + } + defer os.Remove(tempKey.Name()) + + // Clone Client with new TLS Settings + apiClientConfig := vaultClient.CloneConfig() + + tlsConfig := &api.TLSConfig{ + ClientKey: tempKey.Name(), + ClientCert: tempCrt.Name(), + } + + err = apiClientConfig.ConfigureTLS(tlsConfig) + if err != nil { + return err + } + + certVaultClient, err := api.NewClient(apiClientConfig) + + if err != nil { + return err + } + + utils.VerboseToStdErr("Hashicorp Vault authenticating with certificate") + + certVaultClient.ClearToken() + data, err := certVaultClient.Logical().Write(fmt.Sprintf("%s/login", a.MountPath), payload) + if err != nil { + return err + } + + utils.VerboseToStdErr("Hashicorp Vault authentication response: %v", data) + + // If we cannot write the Vault token, we'll just have to login next time. Nothing showstopping. + if err = utils.SetToken(vaultClient, data.Auth.ClientToken); err != nil { + utils.VerboseToStdErr("Hashicorp Vault cannot cache token for future runs: %v", err) + } + + return nil +} diff --git a/pkg/auth/vault/certificate_test.go b/pkg/auth/vault/certificate_test.go new file mode 100644 index 00000000..0004c5b4 --- /dev/null +++ b/pkg/auth/vault/certificate_test.go @@ -0,0 +1,41 @@ +package vault_test + +import ( + "bytes" + "testing" + + "github.com/argoproj-labs/argocd-vault-plugin/pkg/auth/vault" + "github.com/argoproj-labs/argocd-vault-plugin/pkg/helpers" + "github.com/argoproj-labs/argocd-vault-plugin/pkg/utils" +) + +func TestCertificateLogin(t *testing.T) { + cluster, _, _ := helpers.CreateTestCertificateVault(t) + defer cluster.Cleanup() + + certificateAuth := vault.NewCertificateAuth("", "", "") + + err := certificateAuth.Authenticate(cluster.Cores[0].Client) + if err != nil { + t.Fatalf("expected no errors but got: %s", err) + } + + cachedToken, err := utils.ReadExistingToken() + if err != nil { + t.Fatalf("expected cached vault token but got: %s", err) + } + + err = certificateAuth.Authenticate(cluster.Cores[0].Client) + if err != nil { + t.Fatalf("expected no errors but got: %s", err) + } + + newCachedToken, err := utils.ReadExistingToken() + if err != nil { + t.Fatalf("expected cached vault token but got: %s", err) + } + + if bytes.Compare(cachedToken, newCachedToken) != 0 { + t.Fatalf("expected same token %s but got %s", cachedToken, newCachedToken) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index bc896701..d314239b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -131,6 +131,12 @@ func New(v *viper.Viper, co *Options) (*Config, error) { } else { return nil, fmt.Errorf("%s and %s for userpass authentication cannot be empty", types.EnvAvpUsername, types.EnvAvpPassword) } + case types.CertificateAuth: + if v.IsSet(types.EnvAvpCert) && v.IsSet(types.EnvAvpKey) { + auth = vault.NewCertificateAuth(v.GetString(types.EnvAvpCert), v.GetString(types.EnvAvpKey), v.GetString(types.EnvAvpMountPath)) + } else { + return nil, fmt.Errorf("%s and %s for certificate authentication cannot be empty", types.EnvAvpCert, types.EnvAvpKey) + } default: return nil, fmt.Errorf("Must provide a supported Authentication Type, received %s", authType) } diff --git a/pkg/helpers/test_helpers.go b/pkg/helpers/test_helpers.go index e12f8636..792f5e89 100644 --- a/pkg/helpers/test_helpers.go +++ b/pkg/helpers/test_helpers.go @@ -1,19 +1,27 @@ package helpers import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" - "net" - "strconv" - "testing" - "github.com/hashicorp/go-hclog" kv "github.com/hashicorp/vault-plugin-secrets-kv" "github.com/hashicorp/vault/api" credAppRole "github.com/hashicorp/vault/builtin/credential/approle" + credCert "github.com/hashicorp/vault/builtin/credential/cert" credUserPass "github.com/hashicorp/vault/builtin/credential/userpass" + "github.com/hashicorp/vault/builtin/logical/pki" "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" + "math/big" + "net" + "strconv" + "testing" + "time" ) // Test Constants @@ -295,6 +303,219 @@ func CreateTestAppRoleVault(t *testing.T) (*vault.TestCluster, string, string) { return cluster, roleID, secretID } +// CreateTestAppRoleVault initializes a new test vault with AppRole and Kv v2 +func CreateTestCertificateVault(t *testing.T) (*vault.TestCluster, string, string) { + t.Helper() + + coreConfig := &vault.CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + "kv": kv.Factory, + "pki": pki.Factory, + }, + CredentialBackends: map[string]logical.Factory{ + "cert": credCert.Factory, + }, + } + + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: http.Handler, + Logger: hclog.NewNullLogger(), + }) + + cluster.Start() + + vault.TestWaitActive(t, cluster.Cores[0].Core) + + client := cluster.Cores[0].Client + + client.Sys().Mount("kv", &api.MountInput{ + Type: "kv", + Options: map[string]string{ + "version": "2", + }, + }) + + if err := client.Sys().EnableAuthWithOptions("cert", &api.EnableAuthOptions{ + Type: "cert", + }); err != nil { + t.Fatal(err) + } + + write, err := client.Logical().Write("auth/cert/certs/vault-cert", map[string]interface{}{ + "display_name": "vault-cert", + "policies": "cert-kv,cert-secret", + "certificate": string(cluster.CACertPEM), + }) + if err != nil && write == nil { + return nil, "", "" + } + + // Create Policy for secret/foo + err = client.Sys().PutPolicy("cert-secret", "path \"secret/*\" { capabilities = [\"read\",\"list\"] }") + if err != nil { + t.Fatal(err) + } + + // Create Policy for kv + err = client.Sys().PutPolicy("cert-kv", "path \"kv/*\" { capabilities = [\"read\",\"list\"] }") + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/testing", map[string]interface{}{ + "name": "test-name", + "namespace": "test-namespace", + "version": "1.0", + "replicas": "2", + "tag": "1.0", + "secret-var-value": "dGVzdC1wYXNzd29yZA==", + "secret-var-value2": "dGVzdC1wYXNzd29yZDI=", + "secret-num": "MQ==", + "secret-var-clear": "test-password", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("kv/data/testing", map[string]interface{}{ + "data": map[string]interface{}{ + "name": "test-kv-name", + "namespace": "test-kv-namespace", + "version": "1.2", + "replicas": "3", + "tag": "1.1", + "target-port": 80, + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/foo", map[string]interface{}{ + "secret": "bar", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/json", map[string]interface{}{ + "data": map[string]interface{}{ + "service": map[string]interface{}{ + "enableTLS": true, + "ports": []int{80, 8080}, + }, + "deployment": map[string]interface{}{ + "replicas": 2, + "image": map[string]interface{}{ + "name": "json-test", + "tag": "latest", + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/jsonstring", map[string]interface{}{ + "secret": "{\"credentials\":{\"user\":\"test-user\",\"pass\":\"test-password\"}}", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("kv/data/test", map[string]interface{}{ + "data": map[string]interface{}{ + "hello": "world", + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/bad_test", map[string]interface{}{ + "hello": "world", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("kv/data/versioned", map[string]interface{}{ + "data": map[string]interface{}{ + "secret": "version1", + }, + }) + if err != nil { + t.Fatal(err) + } + _, err = client.Logical().Write("kv/data/versioned", map[string]interface{}{ + "data": map[string]interface{}{ + "secret": "version2", + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/base64", map[string]interface{}{ + "encoded_secret": "ewogICJrZXkxIjogInNlY3JldDEiLAogICJrZXkyIjogInNlY3JldDIiLAogICJrZXkzIjogInNlY3JldDMiCn0K", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Logical().Write("secret/yaml", map[string]interface{}{ + "secret": "---\nkey1: secret1\nkey2: secret2\nkey3: secret3", + }) + if err != nil { + t.Fatal(err) + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + csrTemplate := x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: "vault-cert", + Organization: []string{"Client Org"}, + }, + } + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key) + if err != nil { + t.Fatal(err) + } + + csr, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + t.Fatal(err) + } + + clientCertTemplate := x509.Certificate{ + SerialNumber: big.NewInt(1234567890), // Eine eindeutige Seriennummer + Subject: csr.Subject, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 Jahr Gültigkeit + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientCertBytes, err := x509.CreateCertificate(rand.Reader, &clientCertTemplate, cluster.CACert, csr.PublicKey, cluster.CAKey) + if err != nil { + t.Fatal(err) + } + + clientCertPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: clientCertBytes, + }) + return cluster, string(clientCertPEM), string(privateKeyPEM) +} + // CreateTestGithubVault initializes a new test vault with AppRole and Kv v2 func CreateTestAuthVault(t *testing.T) *vault.TestCluster { t.Helper() diff --git a/pkg/types/constants.go b/pkg/types/constants.go index 49abbdef..cb5c7172 100644 --- a/pkg/types/constants.go +++ b/pkg/types/constants.go @@ -31,6 +31,8 @@ const ( EnvAvpDelineaUser = "AVP_DELINEA_USER" EnvAvpDelineaPassword = "AVP_DELINEA_PASSWORD" EnvAvpDelineaDomain = "AVP_DELINEA_DOMAIN" + EnvAvpCert = "AVP_CERT" + EnvAvpKey = "AVP_KEY" // Backend and Auth Constants VaultBackend = "vault" @@ -49,6 +51,7 @@ const ( GithubAuth = "github" TokenAuth = "token" UserPass = "userpass" + CertificateAuth = "certificate" IAMAuth = "iam" AwsDefaultRegion = "us-east-2" GCPCurrentSecretVersion = "latest"