diff --git a/doc/plugin_server_keymanager_hashicorp_vault.md b/doc/plugin_server_keymanager_hashicorp_vault.md new file mode 100644 index 0000000000..73ec02beb1 --- /dev/null +++ b/doc/plugin_server_keymanager_hashicorp_vault.md @@ -0,0 +1,162 @@ +# Server plugin: KeyManager "hashicorp_vault" + +The `hashicorp_vault` key manager plugin leverages HashiCorp Vault to create, maintain, and rotate key pairs, signing +SVIDs as needed. + +## Configuration + +The plugin accepts the following configuration options: + +| key | type | required | description | default | +|:---------------------|:-------|:---------|:---------------------------------------------------------------------------------------------------------|:---------------------| +| vault_addr | string | | The URL of the Vault server. (e.g., ) | `${VAULT_ADDR}` | +| namespace | string | | Name of the Vault namespace. This is only available in the Vault Enterprise. | `${VAULT_NAMESPACE}` | +| transit_engine_path | string | | Path of the transit engine that stores the keys. | transit | +| ca_cert_path | string | | Path to a CA certificate file used to verify the Vault server certificate. Only PEM format is supported. | `${VAULT_CACERT}` | +| insecure_skip_verify | bool | | If true, vault client accepts any server certificates | false | +| cert_auth | struct | | Configuration for the Client Certificate authentication method | | +| token_auth | struct | | Configuration for the Token authentication method | | +| approle_auth | struct | | Configuration for the AppRole authentication method | | +| k8s_auth | struct | | Configuration for the Kubernetes authentication method | | + +The plugin supports **Client Certificate**, **Token** and **AppRole** authentication methods. + +- **Client Certificate** method authenticates to Vault using a TLS client certificate. +- **Token** method authenticates to Vault using the token in a HTTP Request header. +- **AppRole** method authenticates to Vault using a RoleID and SecretID that are issued from Vault. + +The [`ca_ttl` SPIRE Server configurable](https://github.com/spiffe/spire/blob/main/doc/spire_server.md#server-configuration-file) +should be less than or equal to the Vault's PKI secret engine TTL. +To configure the TTL value, tune the engine. + +e.g. + +```shell +$ vault secrets tune -max-lease-ttl=8760h pki +``` + +The configured token needs to be attached to a policy that has at least the following capabilities: + +```hcl +path "pki/root/sign-intermediate" { + capabilities = ["update"] +} +``` + +## Client Certificate Authentication + +| key | type | required | description | default | +|:----------------------|:-------|:---------|:---------------------------------------------------------------------------------------------------------------------|:-----------------------| +| cert_auth_mount_point | string | | Name of the mount point where TLS certificate auth method is mounted | cert | +| cert_auth_role_name | string | | Name of the Vault role. If given, the plugin authenticates against only the named role. Default to trying all roles. | | +| client_cert_path | string | | Path to a client certificate file. Only PEM format is supported. | `${VAULT_CLIENT_CERT}` | +| client_key_path | string | | Path to a client private key file. Only PEM format is supported. | `${VAULT_CLIENT_KEY}` | + +```hcl + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr = "https://vault.example.org/" + pki_mount_point = "test-pki" + ca_cert_path = "/path/to/ca-cert.pem" + cert_auth { + cert_auth_mount_point = "test-tls-cert-auth" + client_cert_path = "/path/to/client-cert.pem" + client_key_path = "/path/to/client-key.pem" + } + // If specify the role to authenticate with + // cert_auth { + // cert_auth_mount_point = "test-tls-cert-auth" + // cert_auth_role_name = "test" + // client_cert_path = "/path/to/client-cert.pem" + // client_key_path = "/path/to/client-key.pem" + // } + + // If specify the key-pair as an environment variable and use the modified mount point + // cert_auth { + // cert_auth_mount_point = "test-tls-cert-auth" + // } + + // If specify the key-pair as an environment variable and use the default mount point, set the empty structure. + // cert_auth {} + } + } +``` + +## Token Authentication + +| key | type | required | description | default | +|:------|:-------|:---------|:------------------------------------------------|:-----------------| +| token | string | | Token string to set into "X-Vault-Token" header | `${VAULT_TOKEN}` | + +```hcl + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr = "https://vault.example.org/" + pki_mount_point = "test-pki" + ca_cert_path = "/path/to/ca-cert.pem" + token_auth { + token = "" + } + // If specify the token as an environment variable, set the empty structure. + // token_auth {} + } + } +``` + +## AppRole Authentication + +| key | type | required | description | default | +|:-------------------------|:-------|:---------|:-----------------------------------------------------------------|:-----------------------------| +| approle_auth_mount_point | string | | Name of the mount point where the AppRole auth method is mounted | approle | +| approle_id | string | | An identifier of AppRole | `${VAULT_APPROLE_ID}` | +| approle_secret_id | string | | A credential of AppRole | `${VAULT_APPROLE_SECRET_ID}` | + +```hcl + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr = "https://vault.example.org/" + pki_mount_point = "test-pki" + ca_cert_path = "/path/to/ca-cert.pem" + approle_auth { + approle_auth_mount_point = "my-approle-auth" + approle_id = "" // or specified by environment variables + approle_secret_id = "" // or specified by environment variables + } + // If specify the approle_id and approle_secret as an environment variable and use the modified mount point + // approle_auth { + // approle_auth_mount_point = "my-approle-auth" + // } + + // If specify the approle_id and approle_secret as an environment variable and use the default mount point, set the empty structure. + // approle_auth {} + } + } +``` + +## Kubernetes Authentication + +| key | type | required | description | default | +|:---------------------|:-------|:---------|:----------------------------------------------------------------------------------|:-----------| +| k8s_auth_mount_point | string | | Name of the mount point where the Kubernetes auth method is mounted | kubernetes | +| k8s_auth_role_name | string | ✔ | Name of the Vault role. The plugin authenticates against the named role | | +| token_path | string | ✔ | Path to the Kubernetes Service Account Token to use authentication with the Vault | | + +```hcl + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr = "https://vault.example.org/" + pki_mount_point = "test-pki" + ca_cert_path = "/path/to/ca-cert.pem" + k8s_auth { + k8s_auth_mount_point = "my-k8s-auth" + k8s_auth_role_name = "my-role" + token_path = "/path/to/sa-token" + } + + // If specify role name and use the default mount point and token_path + // k8s_auth { + // k8s_auth_role_name = "my-role" + // } + } + } +``` diff --git a/doc/spire_server.md b/doc/spire_server.md index 79852c5f21..6ebf95897a 100644 --- a/doc/spire_server.md +++ b/doc/spire_server.md @@ -16,34 +16,35 @@ This document is a configuration reference for SPIRE Server. It includes informa ## Built-in plugins -| Type | Name | Description | -|--------------------|------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| -| DataStore | [sql](/doc/plugin_server_datastore_sql.md) | An SQL database storage for SQLite, PostgreSQL and MySQL databases for the SPIRE datastore | -| KeyManager | [aws_kms](/doc/plugin_server_keymanager_aws_kms.md) | A key manager which manages keys in AWS KMS | -| KeyManager | [disk](/doc/plugin_server_keymanager_disk.md) | A key manager which manages keys persisted on disk | -| KeyManager | [memory](/doc/plugin_server_keymanager_memory.md) | A key manager which manages unpersisted keys in memory | -| CredentialComposer | [uniqueid](/doc/plugin_server_credentialcomposer_uniqueid.md) | Adds the x509UniqueIdentifier attribute to workload X509-SVIDs. | -| NodeAttestor | [aws_iid](/doc/plugin_server_nodeattestor_aws_iid.md) | A node attestor which attests agent identity using an AWS Instance Identity Document | -| NodeAttestor | [azure_msi](/doc/plugin_server_nodeattestor_azure_msi.md) | A node attestor which attests agent identity using an Azure MSI token | -| NodeAttestor | [gcp_iit](/doc/plugin_server_nodeattestor_gcp_iit.md) | A node attestor which attests agent identity using a GCP Instance Identity Token | -| NodeAttestor | [join_token](/doc/plugin_server_nodeattestor_jointoken.md) | A node attestor which validates agents attesting with server-generated join tokens | -| NodeAttestor | [k8s_sat](/doc/plugin_server_nodeattestor_k8s_sat.md) (deprecated) | A node attestor which attests agent identity using a Kubernetes Service Account token | -| NodeAttestor | [k8s_psat](/doc/plugin_server_nodeattestor_k8s_psat.md) | A node attestor which attests agent identity using a Kubernetes Projected Service Account token | -| NodeAttestor | [sshpop](/doc/plugin_server_nodeattestor_sshpop.md) | A node attestor which attests agent identity using an existing ssh certificate | -| NodeAttestor | [tpm_devid](/doc/plugin_server_nodeattestor_tpm_devid.md) | A node attestor which attests agent identity using a TPM that has been provisioned with a DevID certificate | -| NodeAttestor | [x509pop](/doc/plugin_server_nodeattestor_x509pop.md) | A node attestor which attests agent identity using an existing X.509 certificate | -| UpstreamAuthority | [disk](/doc/plugin_server_upstreamauthority_disk.md) | Uses a CA loaded from disk to sign SPIRE server intermediate certificates. | -| UpstreamAuthority | [aws_pca](/doc/plugin_server_upstreamauthority_aws_pca.md) | Uses a Private Certificate Authority from AWS Certificate Manager to sign SPIRE server intermediate certificates. | -| UpstreamAuthority | [awssecret](/doc/plugin_server_upstreamauthority_awssecret.md) | Uses a CA loaded from AWS SecretsManager to sign SPIRE server intermediate certificates. | -| UpstreamAuthority | [gcp_cas](/doc/plugin_server_upstreamauthority_gcp_cas.md) | Uses a Private Certificate Authority from GCP Certificate Authority Service to sign SPIRE Server intermediate certificates. | -| UpstreamAuthority | [vault](/doc/plugin_server_upstreamauthority_vault.md) | Uses a PKI Secret Engine from HashiCorp Vault to sign SPIRE server intermediate certificates. | -| UpstreamAuthority | [spire](/doc/plugin_server_upstreamauthority_spire.md) | Uses an upstream SPIRE server in the same trust domain to obtain intermediate signing certificates for SPIRE server. | -| UpstreamAuthority | [cert-manager](/doc/plugin_server_upstreamauthority_cert_manager.md) | Uses a referenced cert-manager Issuer to request intermediate signing certificates. | -| Notifier | [gcs_bundle](/doc/plugin_server_notifier_gcs_bundle.md) | A notifier that pushes the latest trust bundle contents into an object in Google Cloud Storage. | -| Notifier | [k8sbundle](/doc/plugin_server_notifier_k8sbundle.md) | A notifier that pushes the latest trust bundle contents into a Kubernetes ConfigMap. | -| BundlePublisher | [aws_s3](/doc/plugin_server_bundlepublisher_aws_s3.md) | Publishes the trust bundle to an Amazon S3 bucket. | -| BundlePublisher | [gcp_cloudstorage](/doc/plugin_server_bundlepublisher_gcp_cloudstorage.md) | Publishes the trust bundle to a Google Cloud Storage bucket. | -| BundlePublisher | [aws_rolesanywhere_trustanchor](/doc/plugin_server_bundlepublisher_aws_rolesanywhere_trustanchor.md) | Publishes the trust bundle to an AWS IAM Roles Anywhere trust anchor. | +| Type | Name | Description | +|--------------------|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| DataStore | [sql](/doc/plugin_server_datastore_sql.md) | An SQL database storage for SQLite, PostgreSQL and MySQL databases for the SPIRE datastore | +| KeyManager | [aws_kms](/doc/plugin_server_keymanager_aws_kms.md) | A key manager which manages keys in AWS KMS | +| KeyManager | [hashicorp_vault](/doc/plugin_server_keymanager_hashicorp_vault.md) | A key manager which manages unpersisted keys in memory | +| KeyManager | [disk](/doc/plugin_server_keymanager_disk.md) | A key manager which manages keys persisted on disk | +| KeyManager | [memory](/doc/plugin_server_keymanager_memory.md) | A key manager which manages unpersisted keys in memory | +| CredentialComposer | [uniqueid](/doc/plugin_server_credentialcomposer_uniqueid.md) | Adds the x509UniqueIdentifier attribute to workload X509-SVIDs. | +| NodeAttestor | [aws_iid](/doc/plugin_server_nodeattestor_aws_iid.md) | A node attestor which attests agent identity using an AWS Instance Identity Document | +| NodeAttestor | [azure_msi](/doc/plugin_server_nodeattestor_azure_msi.md) | A node attestor which attests agent identity using an Azure MSI token | +| NodeAttestor | [gcp_iit](/doc/plugin_server_nodeattestor_gcp_iit.md) | A node attestor which attests agent identity using a GCP Instance Identity Token | +| NodeAttestor | [join_token](/doc/plugin_server_nodeattestor_jointoken.md) | A node attestor which validates agents attesting with server-generated join tokens | +| NodeAttestor | [k8s_sat](/doc/plugin_server_nodeattestor_k8s_sat.md) (deprecated) | A node attestor which attests agent identity using a Kubernetes Service Account token | +| NodeAttestor | [k8s_psat](/doc/plugin_server_nodeattestor_k8s_psat.md) | A node attestor which attests agent identity using a Kubernetes Projected Service Account token | +| NodeAttestor | [sshpop](/doc/plugin_server_nodeattestor_sshpop.md) | A node attestor which attests agent identity using an existing ssh certificate | +| NodeAttestor | [tpm_devid](/doc/plugin_server_nodeattestor_tpm_devid.md) | A node attestor which attests agent identity using a TPM that has been provisioned with a DevID certificate | +| NodeAttestor | [x509pop](/doc/plugin_server_nodeattestor_x509pop.md) | A node attestor which attests agent identity using an existing X.509 certificate | +| UpstreamAuthority | [disk](/doc/plugin_server_upstreamauthority_disk.md) | Uses a CA loaded from disk to sign SPIRE server intermediate certificates. | +| UpstreamAuthority | [aws_pca](/doc/plugin_server_upstreamauthority_aws_pca.md) | Uses a Private Certificate Authority from AWS Certificate Manager to sign SPIRE server intermediate certificates. | +| UpstreamAuthority | [awssecret](/doc/plugin_server_upstreamauthority_awssecret.md) | Uses a CA loaded from AWS SecretsManager to sign SPIRE server intermediate certificates. | +| UpstreamAuthority | [gcp_cas](/doc/plugin_server_upstreamauthority_gcp_cas.md) | Uses a Private Certificate Authority from GCP Certificate Authority Service to sign SPIRE Server intermediate certificates. | +| UpstreamAuthority | [vault](/doc/plugin_server_upstreamauthority_vault.md) | Uses a PKI Secret Engine from HashiCorp Vault to sign SPIRE server intermediate certificates. | +| UpstreamAuthority | [spire](/doc/plugin_server_upstreamauthority_spire.md) | Uses an upstream SPIRE server in the same trust domain to obtain intermediate signing certificates for SPIRE server. | +| UpstreamAuthority | [cert-manager](/doc/plugin_server_upstreamauthority_cert_manager.md) | Uses a referenced cert-manager Issuer to request intermediate signing certificates. | +| Notifier | [gcs_bundle](/doc/plugin_server_notifier_gcs_bundle.md) | A notifier that pushes the latest trust bundle contents into an object in Google Cloud Storage. | +| Notifier | [k8sbundle](/doc/plugin_server_notifier_k8sbundle.md) | A notifier that pushes the latest trust bundle contents into a Kubernetes ConfigMap. | +| BundlePublisher | [aws_s3](/doc/plugin_server_bundlepublisher_aws_s3.md) | Publishes the trust bundle to an Amazon S3 bucket. | +| BundlePublisher | [gcp_cloudstorage](/doc/plugin_server_bundlepublisher_gcp_cloudstorage.md) | Publishes the trust bundle to a Google Cloud Storage bucket. | +| BundlePublisher | [aws_rolesanywhere_trustanchor](/doc/plugin_server_bundlepublisher_rolesanywhere_trustanchor.md) | Publishes the trust bundle to an AWS IAM Roles Anywhere trust anchor. | ## Server configuration file diff --git a/pkg/server/catalog/keymanager.go b/pkg/server/catalog/keymanager.go index 645570c34d..13338bc96d 100644 --- a/pkg/server/catalog/keymanager.go +++ b/pkg/server/catalog/keymanager.go @@ -2,6 +2,7 @@ package catalog import ( "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/server/plugin/keymanager/hashicorpvault" "github.com/spiffe/spire/pkg/server/plugin/keymanager" "github.com/spiffe/spire/pkg/server/plugin/keymanager/awskms" @@ -33,6 +34,7 @@ func (repo *keyManagerRepository) BuiltIns() []catalog.BuiltIn { disk.BuiltIn(), gcpkms.BuiltIn(), azurekeyvault.BuiltIn(), + hashicorpvault.BuiltIn(), memory.BuiltIn(), } } diff --git a/pkg/server/plugin/keymanager/hashicorpvault/hashicorp_vault.go b/pkg/server/plugin/keymanager/hashicorpvault/hashicorp_vault.go new file mode 100644 index 0000000000..64952de8fe --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/hashicorp_vault.go @@ -0,0 +1,490 @@ +package hashicorpvault + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcl" + keymanagerv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/keymanager/v1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/catalog" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "os" + "sync" +) + +const ( + pluginName = "hashicorp_vault" +) + +func BuiltIn() catalog.BuiltIn { + return builtin(New()) +} + +func builtin(p *Plugin) catalog.BuiltIn { + return catalog.MakeBuiltIn(pluginName, + keymanagerv1.KeyManagerPluginServer(p), + configv1.ConfigServiceServer(p), + ) +} + +type keyEntry struct { + PublicKey *keymanagerv1.PublicKey +} + +type pluginHooks struct { + // Used for testing only. + lookupEnv func(string) (string, bool) +} + +// Config provides configuration context for the plugin. +type Config struct { + // A URL of Vault server. (e.g., https://vault.example.com:8443/) + VaultAddr string `hcl:"vault_addr" json:"vault_addr"` + // Name of the Vault namespace + Namespace string `hcl:"namespace" json:"namespace"` + // TransitEnginePath specifies the path to the transit engine to perform key operations. + TransitEnginePath string `hcl:"transit_engine_path" json:"transit_engine_path"` + + // If true, vault client accepts any server certificates. + // It should be used only test environment so on. + InsecureSkipVerify bool `hcl:"insecure_skip_verify" json:"insecure_skip_verify"` + // Path to a CA certificate file that the client verifies the server certificate. + // Only PEM format is supported. + CACertPath string `hcl:"ca_cert_path" json:"ca_cert_path"` + + // Configuration for the Token authentication method + TokenAuth *TokenAuthConfig `hcl:"token_auth" json:"token_auth,omitempty"` + // Configuration for the AppRole authentication method + AppRoleAuth *AppRoleAuthConfig `hcl:"approle_auth" json:"approle_auth,omitempty"` + // Configuration for the Client Certificate authentication method + CertAuth *CertAuthConfig `hcl:"cert_auth" json:"cert_auth,omitempty"` + // Configuration for the Kubernetes authentication method + K8sAuth *K8sAuthConfig `hcl:"k8s_auth" json:"k8s_auth,omitempty"` +} + +// TokenAuthConfig represents parameters for token auth method +type TokenAuthConfig struct { + // Token string to set into "X-Vault-Token" header + Token string `hcl:"token" json:"token"` +} + +// AppRoleAuthConfig represents parameters for AppRole auth method. +type AppRoleAuthConfig struct { + // Name of the mount point where AppRole auth method is mounted. (e.g., /auth//login) + // If the value is empty, use default mount point (/auth/approle) + AppRoleMountPoint string `hcl:"approle_auth_mount_point" json:"approle_auth_mount_point"` + // An identifier that selects the AppRole + RoleID string `hcl:"approle_id" json:"approle_id"` + // A credential that is required for login. + SecretID string `hcl:"approle_secret_id" json:"approle_secret_id"` +} + +// CertAuthConfig represents parameters for cert auth method +type CertAuthConfig struct { + // Name of the mount point where Client Certificate Auth method is mounted. (e.g., /auth//login) + // If the value is empty, use default mount point (/auth/cert) + CertAuthMountPoint string `hcl:"cert_auth_mount_point" json:"cert_auth_mount_point"` + // Name of the Vault role. + // If given, the plugin authenticates against only the named role. + CertAuthRoleName string `hcl:"cert_auth_role_name" json:"cert_auth_role_name"` + // Path to a client certificate file. + // Only PEM format is supported. + ClientCertPath string `hcl:"client_cert_path" json:"client_cert_path"` + // Path to a client private key file. + // Only PEM format is supported. + ClientKeyPath string `hcl:"client_key_path" json:"client_key_path"` +} + +// K8sAuthConfig represents parameters for Kubernetes auth method. +type K8sAuthConfig struct { + // Name of the mount point where Kubernetes auth method is mounted. (e.g., /auth//login) + // If the value is empty, use default mount point (/auth/kubernetes) + K8sAuthMountPoint string `hcl:"k8s_auth_mount_point" json:"k8s_auth_mount_point"` + // Name of the Vault role. + // The plugin authenticates against the named role. + K8sAuthRoleName string `hcl:"k8s_auth_role_name" json:"k8s_auth_role_name"` + // Path to the Kubernetes Service Account Token to use authentication with the Vault. + TokenPath string `hcl:"token_path" json:"token_path"` +} + +// Plugin is the main representation of this keymanager plugin +type Plugin struct { + keymanagerv1.UnsafeKeyManagerServer + configv1.UnsafeConfigServer + + logger hclog.Logger + mu sync.RWMutex + entries map[string]keyEntry + + authMethod AuthMethod + cc *ClientConfig + vc *Client + + hooks pluginHooks +} + +// New returns an instantiated plugin. +func New() *Plugin { + return newPlugin() +} + +// newPlugin returns a new plugin instance. +func newPlugin() *Plugin { + return &Plugin{ + entries: make(map[string]keyEntry), + hooks: pluginHooks{ + lookupEnv: os.LookupEnv, + }, + } +} + +// SetLogger sets a logger +func (p *Plugin) SetLogger(log hclog.Logger) { + p.logger = log +} + +func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + config := new(Config) + + if err := hcl.Decode(&config, req.HclConfiguration); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "unable to decode configuration: %v", err) + } + + p.mu.Lock() + defer p.mu.Unlock() + + am, err := parseAuthMethod(config) + if err != nil { + return nil, err + } + cp, err := p.genClientParams(am, config) + if err != nil { + return nil, err + } + vcConfig, err := NewClientConfig(cp, p.logger) + if err != nil { + return nil, err + } + + p.authMethod = am + p.cc = vcConfig + + if p.vc == nil { + err := p.genVaultClient() + if err != nil { + return nil, err + } + } + + p.logger.Debug("Fetching keys from Vault") + keyEntries, err := p.vc.GetKeys(ctx) + if err != nil { + return nil, err + } + + p.setCache(keyEntries) + + return &configv1.ConfigureResponse{}, nil +} + +func parseAuthMethod(config *Config) (AuthMethod, error) { + var authMethod AuthMethod + if config.TokenAuth != nil { + authMethod = TOKEN + } + + if config.AppRoleAuth != nil { + if err := checkForAuthMethodConfigured(authMethod); err != nil { + return 0, err + } + authMethod = APPROLE + } + + if config.CertAuth != nil { + if err := checkForAuthMethodConfigured(authMethod); err != nil { + return 0, err + } + authMethod = CERT + } + + if config.K8sAuth != nil { + if err := checkForAuthMethodConfigured(authMethod); err != nil { + return 0, err + } + authMethod = K8S + } + + if authMethod != 0 { + return authMethod, nil + } + + return 0, status.Error(codes.InvalidArgument, "one of the available authentication methods must be configured: 'Token, AppRole'") +} + +func checkForAuthMethodConfigured(authMethod AuthMethod) error { + if authMethod != 0 { + return status.Error(codes.InvalidArgument, "only one authentication method can be configured") + } + return nil +} + +func (p *Plugin) genClientParams(method AuthMethod, config *Config) (*ClientParams, error) { + cp := &ClientParams{ + VaultAddr: p.getEnvOrDefault(envVaultAddr, config.VaultAddr), + Namespace: p.getEnvOrDefault(envVaultNamespace, config.Namespace), + TransitEnginePath: p.getEnvOrDefault(envVaultTransitEnginePath, config.TransitEnginePath), + CACertPath: p.getEnvOrDefault(envVaultCACert, config.CACertPath), + TLSSKipVerify: config.InsecureSkipVerify, + } + + switch method { + case TOKEN: + cp.Token = p.getEnvOrDefault(envVaultToken, config.TokenAuth.Token) + case APPROLE: + cp.AppRoleAuthMountPoint = config.AppRoleAuth.AppRoleMountPoint + cp.AppRoleID = p.getEnvOrDefault(envVaultAppRoleID, config.AppRoleAuth.RoleID) + cp.AppRoleSecretID = p.getEnvOrDefault(envVaultAppRoleSecretID, config.AppRoleAuth.SecretID) + case CERT: + cp.CertAuthMountPoint = config.CertAuth.CertAuthMountPoint + cp.CertAuthRoleName = config.CertAuth.CertAuthRoleName + cp.ClientCertPath = p.getEnvOrDefault(envVaultClientCert, config.CertAuth.ClientCertPath) + cp.ClientKeyPath = p.getEnvOrDefault(envVaultClientKey, config.CertAuth.ClientKeyPath) + case K8S: + if config.K8sAuth.K8sAuthRoleName == "" { + return nil, status.Error(codes.InvalidArgument, "k8s_auth_role_name is required") + } + if config.K8sAuth.TokenPath == "" { + return nil, status.Error(codes.InvalidArgument, "token_path is required") + } + cp.K8sAuthMountPoint = config.K8sAuth.K8sAuthMountPoint + cp.K8sAuthRoleName = config.K8sAuth.K8sAuthRoleName + cp.K8sAuthTokenPath = config.K8sAuth.TokenPath + } + + return cp, nil +} + +func (p *Plugin) getEnvOrDefault(envKey, fallback string) string { + if value, ok := p.hooks.lookupEnv(envKey); ok { + return value + } + return fallback +} + +func (p *Plugin) GenerateKey(ctx context.Context, req *keymanagerv1.GenerateKeyRequest) (*keymanagerv1.GenerateKeyResponse, error) { + if req.KeyId == "" { + return nil, status.Error(codes.InvalidArgument, "key id is required") + } + if req.KeyType == keymanagerv1.KeyType_UNSPECIFIED_KEY_TYPE { + return nil, status.Error(codes.InvalidArgument, "key type is required") + } + + p.mu.Lock() + defer p.mu.Unlock() + + spireKeyID := req.KeyId + newKeyEntry, err := p.createKey(ctx, spireKeyID, req.KeyType) + if err != nil { + return nil, err + } + + p.entries[spireKeyID] = *newKeyEntry + + return &keymanagerv1.GenerateKeyResponse{ + PublicKey: newKeyEntry.PublicKey, + }, nil +} + +func (p *Plugin) SignData(ctx context.Context, req *keymanagerv1.SignDataRequest) (*keymanagerv1.SignDataResponse, error) { + if req.KeyId == "" { + return nil, status.Error(codes.InvalidArgument, "key id is required") + } + if req.SignerOpts == nil { + return nil, status.Error(codes.InvalidArgument, "signer opts is required") + } + + p.mu.RLock() + defer p.mu.RUnlock() + + keyEntry, hasKey := p.entries[req.KeyId] + if !hasKey { + return nil, status.Errorf(codes.NotFound, "key %q not found", req.KeyId) + } + + hashAlgo, signingAlgo, err := algosForKMS(keyEntry.PublicKey.Type, req.SignerOpts) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + if p.vc == nil { + err := p.genVaultClient() + if err != nil { + return nil, err + } + } + + signature, err := p.vc.SignData(ctx, req.KeyId, req.Data, hashAlgo, signingAlgo) + if err != nil { + return nil, err + } + + return &keymanagerv1.SignDataResponse{ + Signature: signature, + KeyFingerprint: keyEntry.PublicKey.Fingerprint, + }, nil +} + +func (p *Plugin) GetPublicKey(_ context.Context, req *keymanagerv1.GetPublicKeyRequest) (*keymanagerv1.GetPublicKeyResponse, error) { + if req.KeyId == "" { + return nil, status.Error(codes.InvalidArgument, "key id is required") + } + + p.mu.RLock() + defer p.mu.RUnlock() + + entry, ok := p.entries[req.KeyId] + if !ok { + return nil, status.Errorf(codes.NotFound, "key %q not found", req.KeyId) + } + + return &keymanagerv1.GetPublicKeyResponse{ + PublicKey: entry.PublicKey, + }, nil +} + +func (p *Plugin) GetPublicKeys(context.Context, *keymanagerv1.GetPublicKeysRequest) (*keymanagerv1.GetPublicKeysResponse, error) { + var keys = make([]*keymanagerv1.PublicKey, 0, len(p.entries)) + + p.mu.RLock() + defer p.mu.RUnlock() + + for _, key := range p.entries { + keys = append(keys, key.PublicKey) + } + + return &keymanagerv1.GetPublicKeysResponse{PublicKeys: keys}, nil +} + +func algosForKMS(keyType keymanagerv1.KeyType, signerOpts any) (TransitHashAlgorithm, TransitSignatureAlgorithm, error) { + var ( + hashAlgo keymanagerv1.HashAlgorithm + isPSS bool + ) + + switch opts := signerOpts.(type) { + case *keymanagerv1.SignDataRequest_HashAlgorithm: + hashAlgo = opts.HashAlgorithm + isPSS = false + case *keymanagerv1.SignDataRequest_PssOptions: + if opts.PssOptions == nil { + return "", "", errors.New("PSS options are required") + } + hashAlgo = opts.PssOptions.HashAlgorithm + isPSS = true + // opts.PssOptions.SaltLength is handled by Vault. The salt length matches the bits of the hashing algorithm. + default: + return "", "", fmt.Errorf("unsupported signer opts type %T", opts) + } + + isRSA := keyType == keymanagerv1.KeyType_RSA_2048 || keyType == keymanagerv1.KeyType_RSA_4096 + + switch { + case hashAlgo == keymanagerv1.HashAlgorithm_UNSPECIFIED_HASH_ALGORITHM: + return "", "", errors.New("hash algorithm is required") + case keyType == keymanagerv1.KeyType_EC_P256 || keyType == keymanagerv1.KeyType_EC_P384: + return TransitHashAlgorithmNone, TransitSignatureSignatureAlgorithmPKCS1v15, nil + case isRSA && !isPSS && hashAlgo == keymanagerv1.HashAlgorithm_SHA256: + return TransitHashAlgorithmSHA256, TransitSignatureSignatureAlgorithmPKCS1v15, nil + case isRSA && !isPSS && hashAlgo == keymanagerv1.HashAlgorithm_SHA384: + return TransitHashAlgorithmSHA384, TransitSignatureSignatureAlgorithmPKCS1v15, nil + case isRSA && !isPSS && hashAlgo == keymanagerv1.HashAlgorithm_SHA512: + return TransitHashAlgorithmSHA512, TransitSignatureSignatureAlgorithmPKCS1v15, nil + case isRSA && isPSS && hashAlgo == keymanagerv1.HashAlgorithm_SHA256: + return TransitHashAlgorithmSHA256, TransitSignatureSignatureAlgorithmPSS, nil + case isRSA && isPSS && hashAlgo == keymanagerv1.HashAlgorithm_SHA384: + return TransitHashAlgorithmSHA384, TransitSignatureSignatureAlgorithmPSS, nil + case isRSA && isPSS && hashAlgo == keymanagerv1.HashAlgorithm_SHA512: + return TransitHashAlgorithmSHA512, TransitSignatureSignatureAlgorithmPSS, nil + default: + return "", "", fmt.Errorf("unsupported combination of keytype: %v and hashing algorithm: %v", keyType, hashAlgo) + } +} + +func (p *Plugin) createKey(ctx context.Context, spireKeyID string, keyType keymanagerv1.KeyType) (*keyEntry, error) { + if p.vc == nil { + err := p.genVaultClient() + if err != nil { + return nil, err + } + } + + kt, err := convertToTransitKeyType(keyType) + if err != nil { + return nil, err + } + + err = p.vc.CreateKey(ctx, spireKeyID, *kt) + if err != nil { + return nil, err + } + + return p.vc.getKeyEntry(ctx, spireKeyID) +} + +func convertToTransitKeyType(keyType keymanagerv1.KeyType) (*TransitKeyType, error) { + switch keyType { + case keymanagerv1.KeyType_EC_P256: + return to.Ptr(TransitKeyTypeECDSAP256), nil + case keymanagerv1.KeyType_EC_P384: + return to.Ptr(TransitKeyTypeECDSAP384), nil + case keymanagerv1.KeyType_RSA_2048: + return to.Ptr(TransitKeyTypeRSA2048), nil + case keymanagerv1.KeyType_RSA_4096: + return to.Ptr(TransitKeyTypeRSA4096), nil + default: + return nil, status.Errorf(codes.Internal, "unsupported key type: %v", keyType) + } +} + +func (p *Plugin) genVaultClient() error { + renewCh := make(chan struct{}) + vc, err := p.cc.NewAuthenticatedClient(p.authMethod, renewCh) + if err != nil { + return status.Errorf(codes.Internal, "failed to prepare authenticated client: %v", err) + } + p.vc = vc + + // if renewCh has been closed, the token can not be renewed and may expire, + // it needs to re-authenticate to the Vault. + go func() { + <-renewCh + p.mu.Lock() + defer p.mu.Unlock() + p.vc = nil + p.logger.Debug("Going to re-authenticate to the Vault during the next key manager operation") + }() + + return nil +} + +func makeFingerprint(pkixData []byte) string { + s := sha256.Sum256(pkixData) + return hex.EncodeToString(s[:]) +} + +func (p *Plugin) setCache(keyEntries []*keyEntry) { + // clean previous cache + p.entries = make(map[string]keyEntry) + + // add results to cache + for _, e := range keyEntries { + p.entries[e.PublicKey.Id] = *e + p.logger.Debug("Key loaded", "key_id", e.PublicKey.Id, "key_type", e.PublicKey.Type) + } +} diff --git a/pkg/server/plugin/keymanager/hashicorpvault/hashicorp_vault_test.go b/pkg/server/plugin/keymanager/hashicorpvault/hashicorp_vault_test.go new file mode 100644 index 0000000000..5260ac47c5 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/hashicorp_vault_test.go @@ -0,0 +1,760 @@ +package hashicorpvault + +import ( + "bytes" + "context" + "fmt" + "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/spiffe/spire/pkg/server/plugin/keymanager" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "testing" + "text/template" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/test/plugintest" + "github.com/spiffe/spire/test/spiretest" +) + +func TestPluginConfigure(t *testing.T) { + for _, tt := range []struct { + name string + configTmpl string + plainConfig string + expectMsgPrefix string + expectCode codes.Code + wantAuth AuthMethod + expectNamespace string + envKeyVal map[string]string + expectToken string + expectCertAuthMountPoint string + expectClientCertPath string + expectClientKeyPath string + appRoleAuthMountPoint string + appRoleID string + appRoleSecretID string + expectK8sAuthMountPoint string + expectK8sAuthRoleName string + expectK8sAuthTokenPath string + expectTransitEnginePath string + }{ + { + name: "Configure plugin with Client Certificate authentication params given in config file", + configTmpl: testTokenAuthConfigTpl, + wantAuth: TOKEN, + expectToken: "test-token", + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with Token authentication params given as environment variables", + configTmpl: testTokenAuthConfigWithEnvTpl, + envKeyVal: map[string]string{ + envVaultToken: "test-token", + }, + wantAuth: TOKEN, + expectToken: "test-token", + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with Client Certificate authentication params given in config file", + configTmpl: testCertAuthConfigTpl, + wantAuth: CERT, + expectCertAuthMountPoint: "test-cert-auth", + expectClientCertPath: "testdata/client-cert.pem", + expectClientKeyPath: "testdata/client-key.pem", + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with Client Certificate authentication params given as environment variables", + configTmpl: testCertAuthConfigWithEnvTpl, + envKeyVal: map[string]string{ + envVaultClientCert: "testdata/client-cert.pem", + envVaultClientKey: testClientKey, + }, + wantAuth: CERT, + expectCertAuthMountPoint: "test-cert-auth", + expectClientCertPath: testClientCert, + expectClientKeyPath: testClientKey, + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with AppRole authenticate params given in config file", + configTmpl: testAppRoleAuthConfigTpl, + wantAuth: APPROLE, + appRoleAuthMountPoint: "test-approle-auth", + appRoleID: "test-approle-id", + appRoleSecretID: "test-approle-secret-id", + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with AppRole authentication params given as environment variables", + configTmpl: testAppRoleAuthConfigWithEnvTpl, + envKeyVal: map[string]string{ + envVaultAppRoleID: "test-approle-id", + envVaultAppRoleSecretID: "test-approle-secret-id", + }, + wantAuth: APPROLE, + appRoleAuthMountPoint: "test-approle-auth", + appRoleID: "test-approle-id", + appRoleSecretID: "test-approle-secret-id", + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with Kubernetes authentication params given in config file", + configTmpl: testK8sAuthConfigTpl, + wantAuth: K8S, + expectK8sAuthMountPoint: "test-k8s-auth", + expectK8sAuthTokenPath: "testdata/k8s/token", + expectK8sAuthRoleName: "my-role", + expectTransitEnginePath: "transit", + }, + { + name: "Multiple authentication methods configured", + configTmpl: testMultipleAuthConfigsTpl, + expectCode: codes.InvalidArgument, + expectMsgPrefix: "only one authentication method can be configured", + expectTransitEnginePath: "transit", + }, + { + name: "Configure plugin with transit engine path given in config file", + configTmpl: testConfigWithTransitEnginePathTpl, + wantAuth: TOKEN, + expectToken: "test-token", + expectTransitEnginePath: "test-path", + }, + { + name: "Configure plugin with transit engine path given as environment variables", + configTmpl: testConfigWithTransitEnginePathEnvTpl, + envKeyVal: map[string]string{ + envVaultTransitEnginePath: "test-path", + }, + wantAuth: TOKEN, + expectToken: "test-token", + expectTransitEnginePath: "test-path", + }, + { + name: "Configure plugin with namespace given in config file", + configTmpl: testNamespaceConfigTpl, + wantAuth: TOKEN, + expectNamespace: "test-ns", + expectTransitEnginePath: "transit", + expectToken: "test-token", + }, + { + name: "Configure plugin with given namespace given as environment variable", + configTmpl: testNamespaceEnvTpl, + wantAuth: TOKEN, + envKeyVal: map[string]string{ + envVaultNamespace: "test-ns", + }, + expectNamespace: "test-ns", + expectTransitEnginePath: "transit", + expectToken: "test-token", + }, + { + name: "Malformed configuration", + plainConfig: "invalid-config", + expectCode: codes.InvalidArgument, + expectMsgPrefix: "unable to decode configuration:", + expectTransitEnginePath: "transit", + }, + { + name: "Required parameters are not given / k8s_auth_role_name", + configTmpl: testK8sAuthNoRoleNameTpl, + wantAuth: K8S, + expectCode: codes.InvalidArgument, + expectMsgPrefix: "k8s_auth_role_name is required", + expectTransitEnginePath: "transit", + }, + { + name: "Required parameters are not given / token_path", + configTmpl: testK8sAuthNoTokenPathTpl, + wantAuth: K8S, + expectCode: codes.InvalidArgument, + expectMsgPrefix: "token_path is required", + expectTransitEnginePath: "transit", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer := setupSuccessFakeVaultServer(tt.expectTransitEnginePath) + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + p := New() + p.hooks.lookupEnv = func(s string) (string, bool) { + if len(tt.envKeyVal) == 0 { + return "", false + } + v, ok := tt.envKeyVal[s] + return v, ok + } + + plainConfig := "" + if tt.plainConfig != "" { + plainConfig = tt.plainConfig + } else { + plainConfig = getTestConfigureRequest(t, fmt.Sprintf("https://%v/", addr), tt.configTmpl) + } + plugintest.Load(t, builtin(p), nil, + plugintest.CaptureConfigureError(&err), + plugintest.Configure(plainConfig), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("localhost"), + }), + ) + + spiretest.RequireGRPCStatusHasPrefix(t, err, tt.expectCode, tt.expectMsgPrefix) + if tt.expectCode != codes.OK { + return + } + + require.NotNil(t, p.cc) + require.NotNil(t, p.cc.clientParams) + + switch tt.wantAuth { + case TOKEN: + require.Equal(t, tt.expectToken, p.cc.clientParams.Token) + case CERT: + require.Equal(t, tt.expectCertAuthMountPoint, p.cc.clientParams.CertAuthMountPoint) + require.Equal(t, tt.expectClientCertPath, p.cc.clientParams.ClientCertPath) + require.Equal(t, tt.expectClientKeyPath, p.cc.clientParams.ClientKeyPath) + case APPROLE: + require.NotNil(t, p.cc.clientParams.AppRoleAuthMountPoint) + require.NotNil(t, p.cc.clientParams.AppRoleID) + require.NotNil(t, p.cc.clientParams.AppRoleSecretID) + case K8S: + require.Equal(t, tt.expectK8sAuthMountPoint, p.cc.clientParams.K8sAuthMountPoint) + require.Equal(t, tt.expectK8sAuthRoleName, p.cc.clientParams.K8sAuthRoleName) + require.Equal(t, tt.expectK8sAuthTokenPath, p.cc.clientParams.K8sAuthTokenPath) + } + + require.Equal(t, tt.expectTransitEnginePath, p.cc.clientParams.TransitEnginePath) + require.Equal(t, tt.expectNamespace, p.cc.clientParams.Namespace) + }) + } +} + +func TestPluginGenerateKey(t *testing.T) { + successfulConfig := &Config{ + TransitEnginePath: "test-transit", + CACertPath: "testdata/root-cert.pem", + TokenAuth: &TokenAuthConfig{ + Token: "test-token", + }, + } + + for _, tt := range []struct { + name string + config *Config + authMethod AuthMethod + expectCode codes.Code + expectMsgPrefix string + id string + keyType keymanager.KeyType + + fakeServer func() *FakeVaultServerConfig + }{ + { + name: "Generate EC P-256 key with token auth", + id: "x509-CA-A", + keyType: keymanager.ECP256, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + + return fakeServer + }, + }, + { + name: "Generate P-384 key with token auth", + id: "x509-CA-A", + keyType: keymanager.ECP384, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseP384) + + return fakeServer + }, + }, + { + name: "Generate RSA 2048 key with token auth", + id: "x509-CA-A", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA2048) + + return fakeServer + }, + }, + { + name: "Generate RSA 4096 key with token auth", + id: "x509-CA-A", + keyType: keymanager.RSA4096, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA4096) + + return fakeServer + }, + }, + { + name: "Generate key with missing id", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA2048) + + return fakeServer + }, + expectCode: codes.InvalidArgument, + expectMsgPrefix: "keymanager(hashicorp_vault): key id is required", + }, + { + name: "Generate key with missing key type", + id: "x509-CA-A", + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA2048) + + return fakeServer + }, + expectCode: codes.InvalidArgument, + expectMsgPrefix: "keymanager(hashicorp_vault): key type is required", + }, + { + name: "Generate key with unsupported key type", + id: "x509-CA-A", + keyType: 100, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA2048) + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "keymanager(hashicorp_vault): facade does not support key type \"UNKNOWN(100)\"", + }, + { + name: "Malformed get key response", + id: "x509-CA-A", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte("error") + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "keymanager(hashicorp_vault): failed to get transit engine key: invalid character", + }, + { + name: "Malformed create key response", + id: "x509-CA-A", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.CreateKeyResponse = []byte("error") + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "keymanager(hashicorp_vault): failed to create transit engine key: invalid character", + }, + { + name: "Bad get key response code", + id: "x509-CA-A", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponseCode = 500 + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "keymanager(hashicorp_vault): failed to get transit engine key: Error making API request.", + }, + { + name: "Bad create key response code", + id: "x509-CA-A", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.CreateKeyResponseCode = 500 + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "keymanager(hashicorp_vault): failed to create transit engine key: Error making API request.", + }, + { + name: "Malformed key", + id: "x509-CA-A", + keyType: keymanager.RSA2048, + config: successfulConfig, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("test-transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseMalformed) + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "keymanager(hashicorp_vault): unable to decode PEM key", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer := tt.fakeServer() + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + p := New() + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{TrustDomain: spiffeid.RequireTrustDomainFromString("example.org")}), + } + if tt.config != nil { + tt.config.VaultAddr = fmt.Sprintf("https://%s", addr) + cp, err := p.genClientParams(tt.authMethod, tt.config) + require.NoError(t, err) + cc, err := NewClientConfig(cp, p.logger) + require.NoError(t, err) + p.cc = cc + options = append(options, plugintest.ConfigureJSON(tt.config)) + } + p.authMethod = tt.authMethod + + v1 := new(keymanager.V1) + plugintest.Load(t, builtin(p), v1, + options..., + ) + + key, err := v1.GenerateKey(context.Background(), tt.id, tt.keyType) + + spiretest.RequireGRPCStatusHasPrefix(t, err, tt.expectCode, tt.expectMsgPrefix) + if tt.expectCode != codes.OK { + require.Nil(t, key) + return + } + + require.NotNil(t, key) + require.Equal(t, tt.id, key.ID()) + + if p.cc.clientParams.Namespace != "" { + headers := p.vc.vaultClient.Headers() + require.Equal(t, p.cc.clientParams.Namespace, headers.Get(consts.NamespaceHeaderName)) + } + }) + } +} + +func TestPluginGetKey(t *testing.T) { + for _, tt := range []struct { + name string + config *Config + configTmpl string + authMethod AuthMethod + expectCode codes.Code + expectMsgPrefix string + id string + + fakeServer func() *FakeVaultServerConfig + }{ + { + name: "Get EC P-256 key with token auth", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + + return fakeServer + }, + }, + { + name: "Get P-384 key with token auth", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseP384) + + return fakeServer + }, + }, + { + name: "Get RSA 2048 key with token auth", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA2048) + + return fakeServer + }, + }, + { + name: "Get RSA 4096 key with token auth", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA4096) + + return fakeServer + }, + }, + { + name: "Get key with missing id", + configTmpl: testTokenAuthConfigTpl, + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseRSA2048) + + return fakeServer + }, + expectCode: codes.InvalidArgument, + expectMsgPrefix: "keymanager(hashicorp_vault): key id is required", + }, + { + name: "Malformed get key response", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte("error") + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "failed to get transit engine key:", + }, + { + name: "Bad get key response code", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponseCode = 500 + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "failed to get transit engine key:", + }, + { + name: "Malformed key", + configTmpl: testTokenAuthConfigTpl, + id: "x509-CA-A", + authMethod: TOKEN, + fakeServer: func() *FakeVaultServerConfig { + fakeServer := setupSuccessFakeVaultServer("transit") + fakeServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeServer.CertAuthResponse = []byte{} + fakeServer.AppRoleAuthResponse = []byte{} + fakeServer.GetKeyResponse = []byte(testGetKeyResponseMalformed) + + return fakeServer + }, + expectCode: codes.Internal, + expectMsgPrefix: "unable to decode PEM key", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer := tt.fakeServer() + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + p := New() + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.Configure(getTestConfigureRequest(t, fmt.Sprintf("https://%v/", addr), tt.configTmpl)), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + } + + v1 := new(keymanager.V1) + plugintest.Load(t, builtin(p), v1, + options..., + ) + + if err != nil { + spiretest.RequireGRPCStatusHasPrefix(t, err, tt.expectCode, tt.expectMsgPrefix) + return + } + + key, err := v1.GetKey(context.Background(), tt.id) + + spiretest.RequireGRPCStatusHasPrefix(t, err, tt.expectCode, tt.expectMsgPrefix) + if tt.expectCode != codes.OK { + require.Nil(t, key) + return + } + + require.NotNil(t, key) + require.Equal(t, tt.id, key.ID()) + + if p.cc.clientParams.Namespace != "" { + headers := p.vc.vaultClient.Headers() + require.Equal(t, p.cc.clientParams.Namespace, headers.Get(consts.NamespaceHeaderName)) + } + }) + } +} + +// TODO: Should the Sign function also be tested? + +func getTestConfigureRequest(t *testing.T, addr string, tpl string) string { + templ, err := template.New("plugin config").Parse(tpl) + require.NoError(t, err) + + cp := &struct{ Addr string }{Addr: addr} + + var c bytes.Buffer + err = templ.Execute(&c, cp) + require.NoError(t, err) + + return c.String() +} + +func setupSuccessFakeVaultServer(transitEnginePath string) *FakeVaultServerConfig { + fakeVaultServer := setupFakeVaultServer() + + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.CertAuthReqEndpoint = "/v1/auth/test-cert-auth/login" + + fakeVaultServer.AppRoleAuthResponseCode = 200 + fakeVaultServer.AppRoleAuthResponse = []byte(testAppRoleAuthResponse) + fakeVaultServer.AppRoleAuthReqEndpoint = "/v1/auth/test-approle-auth/login" + + fakeVaultServer.K8sAuthResponseCode = 200 + fakeVaultServer.K8sAuthReqEndpoint = "/v1/auth/test-k8s-auth/login" + fakeVaultServer.K8sAuthResponse = []byte(testK8sAuthResponse) + + fakeVaultServer.LookupSelfResponse = []byte(testLookupSelfResponse) + fakeVaultServer.LookupSelfReqEndpoint = "GET /v1/auth/token/lookup-self" + fakeVaultServer.LookupSelfResponseCode = 200 + + fakeVaultServer.CreateKeyResponseCode = 200 + fakeVaultServer.CreateKeyReqEndpoint = fmt.Sprintf("PUT /v1/%s/keys/{id}", transitEnginePath) + + fakeVaultServer.GetKeyResponseCode = 200 + fakeVaultServer.GetKeyReqEndpoint = fmt.Sprintf("GET /v1/%s/keys/{id}", transitEnginePath) + fakeVaultServer.GetKeyResponse = []byte(testGetKeyResponseP256) + + fakeVaultServer.GetKeysResponseCode = 200 + fakeVaultServer.GetKeysReqEndpoint = fmt.Sprintf("GET /v1/%s/keys", transitEnginePath) + fakeVaultServer.GetKeysResponse = []byte(testGetKeysResponseOneKey) + + return fakeVaultServer +} + +func setupFakeVaultServer() *FakeVaultServerConfig { + fakeVaultServer := NewFakeVaultServerConfig() + fakeVaultServer.ServerCertificatePemPath = testServerCert + fakeVaultServer.ServerKeyPemPath = testServerKey + fakeVaultServer.RenewResponseCode = 200 + fakeVaultServer.RenewResponse = []byte(testRenewResponse) + return fakeVaultServer +} diff --git a/pkg/server/plugin/keymanager/hashicorpvault/renewer.go b/pkg/server/plugin/keymanager/hashicorpvault/renewer.go new file mode 100644 index 0000000000..8f14ab5ab3 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/renewer.go @@ -0,0 +1,50 @@ +package hashicorpvault + +import ( + "github.com/hashicorp/go-hclog" + vapi "github.com/hashicorp/vault/api" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + defaultRenewBehavior = vapi.RenewBehaviorIgnoreErrors +) + +type Renew struct { + logger hclog.Logger + watcher *vapi.LifetimeWatcher +} + +func NewRenew(client *vapi.Client, secret *vapi.Secret, logger hclog.Logger) (*Renew, error) { + watcher, err := client.NewLifetimeWatcher(&vapi.LifetimeWatcherInput{ + Secret: secret, + RenewBehavior: defaultRenewBehavior, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to initialize Renewer: %v", err) + } + return &Renew{ + logger: logger, + watcher: watcher, + }, nil +} + +func (r *Renew) Run() { + go r.watcher.Start() + defer r.watcher.Stop() + + for { + select { + case err := <-r.watcher.DoneCh(): + if err != nil { + r.logger.Error("Failed to renew auth token", "err", err) + return + } + r.logger.Error("Failed to renew auth token. Retries may have exceeded the lease time threshold") + return + case renewal := <-r.watcher.RenewCh(): + r.logger.Debug("Successfully renew auth token", "request_id", renewal.Secret.RequestID, "lease_duration", renewal.Secret.Auth.LeaseDuration) + } + } +} diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/client-cert.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/client-cert.pem new file mode 100644 index 0000000000..ab411834a0 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/client-cert.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBKDCBz6ADAgECAgEDMAoGCCqGSM49BAMCMAAwIBgPMDAwMTAxMDEwMDAwMDBa +Fw0zMjA0MTIxNjA4NDRaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQymtYU +je8Cue4bRUr76kUGb5F2iyM/Isxt8khYmCRi3TsW21NrOGHmFpIWQ6OVya7UHR0v +QbutQJAflrR12cqeozgwNjATBgNVHSUEDDAKBggrBgEFBQcDAjAfBgNVHSMEGDAW +gBSYSzYwHNQsGiZXSVYDs59w3+UYNzAKBggqhkjOPQQDAgNIADBFAiEAzcRL2tVT +GpPtq6sJKN9quQcX8xxHq7NAxQ8u10C6UegCIECAEW+D8mNP2nM5J+6eSE7DGQ5d +FQZvf0i+L7y0UQQ3 +-----END CERTIFICATE----- diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/client-key.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/client-key.pem new file mode 100644 index 0000000000..c9fcac5019 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgC3sFQg3WCrosxeWb +pT67H8HE/lOcPq+zc6BMss947J6hRANCAAQymtYUje8Cue4bRUr76kUGb5F2iyM/ +Isxt8khYmCRi3TsW21NrOGHmFpIWQ6OVya7UHR0vQbutQJAflrR12cqe +-----END PRIVATE KEY----- diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-client-cert.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-client-cert.pem new file mode 100644 index 0000000000..7ec3efa757 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-client-cert.pem @@ -0,0 +1 @@ +"invalid-client-cert-file" diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-client-key.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-client-key.pem new file mode 100644 index 0000000000..2ce22f3da6 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-client-key.pem @@ -0,0 +1 @@ +"invalid-client-key-file" diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-root-cert.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-root-cert.pem new file mode 100644 index 0000000000..c224998f83 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/invalid-root-cert.pem @@ -0,0 +1 @@ +"invalid-root-cert-file" diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/k8s/signing-key.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/k8s/signing-key.pem new file mode 100644 index 0000000000..c6bb5eb4a4 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/k8s/signing-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEnwIBAAKCAQAwrCHZ8ldBNltOjJTUMWopdAuHGcxuPUsTjdaoZL71q6YC8TbD +cD5aFX152g17tfSHbukr53YD+0TfrDcL/vdSt7Acs5FUHK1ULcuzGvhXx2rUiosW +Zk8Nc99gjwHXOV3DoUBVk04edXo7SMmVKPiYemwm0XvSoBhU3NpnBGJ/DQq7TG+W +wFIaxbURpVxpUP2oWZRebUuQgund8Pjh6kxUkX6XcFH+0y4+wMDV3YdLTuFTYwEc +q/XqdUIEasc1lPT7CwwAlxR+jQTKGnDji6KQerSiktwOUjBpQVb/j/m2+53suhju +XHLUcId2x9yfe73kTTMcYsQ4woEHt9xGRniJAgMBAAECggEAKC5y49LFZfjR+E7m +ryb8VayPt8D8nCXNzR7Tj8FcRMSoENXCOCZ50zTambYCW5cjgIt3w98Z9r+BZIZw +C186Hve2VHuKBr6F+XC1Me+aBh2DfGPD34Im0RxP1Q86ncumNNLyobMyUsL5XegB +QzrHwFmQ35shdgjlDWomg9WC2w/Y2P6zLpbua/lZNBBo3ISXdU1EZNdCl6cJct5N +Q9bbr6PJrL1JdQIC8fA0c1MXiN5XCAaVqSuxlLqiTVrNcTPweJb4iHbvgNf7pvwT +kPEH/10dQirdtjFPR8+WmihES8lIWBcqqemB/dpDoLpjyTo3ZcLODKQiE4o0gLyu +Mw+C4QKBgHOXDRwH/7rxif4S+ngxcJS1OKigfHbf0JLdEVX6X6y5QkGZAd9c3hgP +FbroBx0XXohrLaXcaDVAx8DpylPum2NuibsTg/HUToc9FxH5PXYYp15Thjai2KJ0 +zMjV/Z3DuzJ8465Cv0QL7kuCalgilEU01F03zVaIgnm1ZBjZyAtfAoGAa8u/x6C4 +9VNdYSgIhDzPPmTaxWy6jWFZV5mcmRckHqYGQFw8c9VFCFA07endetbn+3SniDi5 +ujnNV+HStLTHq5uv1QkqCWFXc8B06vKcfLbwsHCzPOcRz7NHGfICQpHKo64R+/un +RWJv1KO1u0gvMy/4/OJXDYFn2YsZ5CFKbRcCgYBXXIav9Ou24u8kdDuRs+weuIjG +CeWIAsik9ygvDzhYVvxYj8f2hT3meSA3Tz5xIkR0Xmz1uouYFAnlJ82fees/T0AR +gEJs98USOX3CO9nT8/YrOH1rtdB9mEFeWT2Bi3lkQzfhcNkWGN5Ve4/cZOYjGDaY +7Z/oEuxqCEpK7e5fiQKBgAqQ9kODJZ4mhci4O916eHYNPMSNW9vv5uoHTKpU8l1u +uL4mTGauSQ3/jrCjc+pOln63eJSJuureL5qlsBm2frv7jsi7FTvGJuRZwRwmm+A9 +rmodIfSeUciiMh4A8ufDkrFopqqkiEjs1Tlqsq2g7b9+vFFNfmr8fEl+sRMDkGAR +AoGACfGod8qGIMX8gxRiLGPVK98wJAJhlLxeVztoSP3pQbpyf+GU7r+Nf8DyzhE5 +xomJ1aF25lBMSGSo74QZgIpFxcNKbR+9zcYpzsSJfq9vIktvgLYPn9g7GDr1rWMi +r8G7GT0udgiJIODc7JFGuBDid4iwlHQZdCFmot3gfBbpsJk= +-----END RSA PRIVATE KEY----- diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/k8s/token b/pkg/server/plugin/keymanager/hashicorpvault/testdata/k8s/token new file mode 100644 index 0000000000..a9a0a94d73 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/k8s/token @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6Ik1ZWTNhcmZRaWVTRzlrR3Y0NE5JSEpmWHB6aUswVFRibFBQQ3ZjN1ZFX0UifQ.eyJhdWQiOlsiYXBpIiwic3BpcmUtc2VydmVyIl0sImV4cCI6NDgxMDQxMzg1MywiaWF0IjoxNjIzMjA0MjUzLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6InNwaXJlIiwicG9kIjp7Im5hbWUiOiJzcGlyZS1zZXJ2ZXItMCIsInVpZCI6ImY5MDIzNzAyLWY0ZWQtNGVkOS1hYjQwLWJjNjkxNDJhYTlhNiJ9LCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoic3BpcmUtc2VydmVyIiwidWlkIjoiNjgwOGI0YzctMGI1My00NWY0LTgzZjctZTg5Mzc3NTZlZWFlIn19LCJuYmYiOjE2MjMyMDQyNTMsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpzcGlyZTpzcGlyZS1zZXJ2ZXIifQ.G_dFjt8NzCFq-_QRm8Kbvq4Lt2iJN7Eos57k82aj2dS4TEMkefc2D07MLG4Sur3f2TYZ0xt51Cp3tCKaH8trUyS7sM07_gPO1GLtj-sAKgiRSjrbLPh2Du_J7Rapb42CN77Nb9EhZcc-B1zSg-J56Ypnl54M4UDotbYxIdHEHNvVWQf4KPP2X2IX47b_7Osm1p1jE3p086F6xSA3iDTIIpa6c1Ch3EzjXPK7XgdEDaVpI0TyrO2r2wBeVDTXSO0E8GWzSnaMnAPzypmdSK7jhD0bpF1SClLTC7PCbkqF6K9C-dQM0F-QWoM1hPMTJGG5bQy_xtQS6PT_b-uPUYNpzA diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/root-cert.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/root-cert.pem new file mode 100644 index 0000000000..0c02782af5 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/root-cert.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBMjCB2aADAgECAgEBMAoGCCqGSM49BAMCMAAwIBgPMDAwMTAxMDEwMDAwMDBa +Fw0zMjA0MTIxNjA4NDRaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQaWBAL +TN4YPe4yQgMhDp9DZOPXaglEchzUo++feITLXN9XuUICLNWO9YEtAsaRsajul8Bc +GL9Rmbv2f6J2Lnueo0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUmEs2MBzULBomV0lWA7OfcN/lGDcwCgYIKoZIzj0EAwIDSAAw +RQIhAP86wRV1PHg6rFkjl1Nx6He+Y2LSdOoEGnGlVM0ztzlUAiBpPhSMqonlFLZa +nLW9psyWrQMHai7KZLJjLfw+UMl0sQ== +-----END CERTIFICATE----- diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/server-cert.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/server-cert.pem new file mode 100644 index 0000000000..d12aa83669 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/server-cert.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBPTCB46ADAgECAgECMAoGCCqGSM49BAMCMAAwIBgPMDAwMTAxMDEwMDAwMDBa +Fw0zMjA0MTIxNjA4NDRaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS6v/nm +XmVkQGMfqDpEq6aiV/AnwcGAJBGTL/ixbDqCPD5crgrXaycLdbZqy8jYVA5uWfHh +Ps+5/8acn3cSSAc2o0wwSjATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAW +gBSYSzYwHNQsGiZXSVYDs59w3+UYNzASBgNVHREBAf8ECDAGhwR/AAABMAoGCCqG +SM49BAMCA0kAMEYCIQDkCDZP2InFWBBazaVJZlIwMz/o2cm3K7xaPbVucHPuswIh +AJstcTQ/RjJKhfZQo7mOIHO+l5U0TeInMCYg9XEPcNJt +-----END CERTIFICATE----- diff --git a/pkg/server/plugin/keymanager/hashicorpvault/testdata/server-key.pem b/pkg/server/plugin/keymanager/hashicorpvault/testdata/server-key.pem new file mode 100644 index 0000000000..dc98bde505 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/testdata/server-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjHE1FFYDxseFqNrC +jjh72BLj5tHTh5vIMcdn0w3W1PKhRANCAAS6v/nmXmVkQGMfqDpEq6aiV/AnwcGA +JBGTL/ixbDqCPD5crgrXaycLdbZqy8jYVA5uWfHhPs+5/8acn3cSSAc2 +-----END PRIVATE KEY----- diff --git a/pkg/server/plugin/keymanager/hashicorpvault/vault_client.go b/pkg/server/plugin/keymanager/hashicorpvault/vault_client.go new file mode 100644 index 0000000000..1743b8988a --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/vault_client.go @@ -0,0 +1,554 @@ +package hashicorpvault + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "github.com/hashicorp/go-hclog" + vapi "github.com/hashicorp/vault/api" + "github.com/imdario/mergo" + keymanagerv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/keymanager/v1" + "golang.org/x/net/context" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "net/http" + "os" + "strings" + + "github.com/spiffe/spire/pkg/common/pemutil" +) + +const ( + envVaultAddr = "VAULT_ADDR" + envVaultToken = "VAULT_TOKEN" + envVaultClientCert = "VAULT_CLIENT_CERT" + envVaultClientKey = "VAULT_CLIENT_KEY" + envVaultCACert = "VAULT_CACERT" + envVaultAppRoleID = "VAULT_APPROLE_ID" + envVaultAppRoleSecretID = "VAULT_APPROLE_SECRET_ID" // #nosec G101 + envVaultNamespace = "VAULT_NAMESPACE" + envVaultTransitEnginePath = "VAULT_TRANSIT_ENGINE_PATH" + + defaultCertMountPoint = "cert" + defaultPKIMountPoint = "pki" + defaultTransitEnginePath = "transit" + defaultAppRoleMountPoint = "approle" + defaultK8sMountPoint = "kubernetes" +) + +type AuthMethod int + +const ( + _ AuthMethod = iota + CERT + TOKEN + APPROLE + K8S +) + +// ClientConfig represents configuration parameters for vault client +type ClientConfig struct { + Logger hclog.Logger + // vault client parameters + clientParams *ClientParams +} + +type ClientParams struct { + // A URL of Vault server. (e.g., https://vault.example.com:8443/) + VaultAddr string + // Name of mount point where PKI secret engine is mounted. (e.e., //ca/pem ) + PKIMountPoint string + // token string to use when auth method is 'token' + Token string + // Name of mount point where TLS Cert auth method is mounted. (e.g., /auth//login ) + CertAuthMountPoint string + // Name of the Vault role. + // If given, the plugin authenticates against only the named role + CertAuthRoleName string + // Path to a client certificate file to be used when auth method is 'cert' + ClientCertPath string + // Path to a client private key file to be used when auth method is 'cert' + ClientKeyPath string + // Path to a CA certificate file to be used when client verifies a server certificate + CACertPath string + // Name of mount point where AppRole auth method is mounted. (e.g., /auth//login ) + AppRoleAuthMountPoint string + // An identifier of AppRole + AppRoleID string + // A credential set of AppRole + AppRoleSecretID string + // Name of the mount point where Kubernetes auth method is mounted. (e.g., /auth//login) + K8sAuthMountPoint string + // Name of the Vault role. + // The plugin authenticates against the named role. + K8sAuthRoleName string + // Path to a K8s Service Account Token to be used when auth method is 'k8s' + K8sAuthTokenPath string + // If true, client accepts any certificates. + // It should be used only test environment so on. + TLSSKipVerify bool + // MaxRetries controls the number of times to retry to connect + // Set to 0 to disable retrying. + // If the value is nil, to use the default in hashicorp/vault/api. + MaxRetries *int + // Name of the Vault namespace + Namespace string + // TransitEnginePath specifies the path to the transit engine to perform key operations. + TransitEnginePath string +} + +type Client struct { + vaultClient *vapi.Client + clientParams *ClientParams +} + +// NewClientConfig returns a new *ClientConfig with default parameters. +func NewClientConfig(cp *ClientParams, logger hclog.Logger) (*ClientConfig, error) { + cc := &ClientConfig{ + Logger: logger, + } + defaultParams := &ClientParams{ + CertAuthMountPoint: defaultCertMountPoint, + AppRoleAuthMountPoint: defaultAppRoleMountPoint, + K8sAuthMountPoint: defaultK8sMountPoint, + PKIMountPoint: defaultPKIMountPoint, + TransitEnginePath: defaultTransitEnginePath, + } + if err := mergo.Merge(cp, defaultParams); err != nil { + return nil, status.Errorf(codes.Internal, "unable to merge client params: %v", err) + } + cc.clientParams = cp + return cc, nil +} + +// NewAuthenticatedClient returns a new authenticated vault client with given authentication method +func (c *ClientConfig) NewAuthenticatedClient(method AuthMethod, renewCh chan struct{}) (client *Client, err error) { + config := vapi.DefaultConfig() + config.Address = c.clientParams.VaultAddr + if c.clientParams.MaxRetries != nil { + config.MaxRetries = *c.clientParams.MaxRetries + } + + if err := c.configureTLS(config); err != nil { + return nil, err + } + vc, err := vapi.NewClient(config) + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to create Vault client: %v", err) + } + + if c.clientParams.Namespace != "" { + vc.SetNamespace(c.clientParams.Namespace) + } + + client = &Client{ + vaultClient: vc, + clientParams: c.clientParams, + } + + var sec *vapi.Secret + switch method { + case TOKEN: + sec, err = client.LookupSelf(c.clientParams.Token) + if err != nil { + return nil, err + } + if sec == nil { + return nil, status.Error(codes.Internal, "lookup self response is nil") + } + case CERT: + path := fmt.Sprintf("auth/%v/login", c.clientParams.CertAuthMountPoint) + sec, err = client.Auth(path, map[string]any{ + "name": c.clientParams.CertAuthRoleName, + }) + if err != nil { + return nil, err + } + if sec == nil { + return nil, status.Error(codes.Internal, "tls cert authentication response is nil") + } + case APPROLE: + path := fmt.Sprintf("auth/%v/login", c.clientParams.AppRoleAuthMountPoint) + body := map[string]any{ + "role_id": c.clientParams.AppRoleID, + "secret_id": c.clientParams.AppRoleSecretID, + } + sec, err = client.Auth(path, body) + if err != nil { + return nil, err + } + if sec == nil { + return nil, status.Error(codes.Internal, "approle authentication response is nil") + } + case K8S: + b, err := os.ReadFile(c.clientParams.K8sAuthTokenPath) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to read k8s service account token: %v", err) + } + path := fmt.Sprintf("auth/%s/login", c.clientParams.K8sAuthMountPoint) + body := map[string]any{ + "role": c.clientParams.K8sAuthRoleName, + "jwt": string(b), + } + sec, err = client.Auth(path, body) + if err != nil { + return nil, err + } + if sec == nil { + return nil, status.Error(codes.Internal, "k8s authentication response is nil") + } + } + + err = handleRenewToken(vc, sec, renewCh, c.Logger) + if err != nil { + return nil, err + } + + return client, nil +} + +// handleRenewToken handles renewing the vault token. +// if the token is non-renewable or renew failed, renewCh will be closed. +func handleRenewToken(vc *vapi.Client, sec *vapi.Secret, renewCh chan struct{}, logger hclog.Logger) error { + if sec == nil || sec.Auth == nil { + return status.Error(codes.InvalidArgument, "secret is nil") + } + + if sec.Auth.LeaseDuration == 0 { + logger.Debug("Token will never expire") + return nil + } + if !sec.Auth.Renewable { + logger.Debug("Token is not renewable") + close(renewCh) + return nil + } + renew, err := NewRenew(vc, sec, logger) + if err != nil { + logger.Error("unable to create renew", err) + return err + } + + go func() { + defer close(renewCh) + renew.Run() + }() + + logger.Debug("Token will be renewed") + + return nil +} + +// ConfigureTLS Configures TLS for Vault Client +func (c *ClientConfig) configureTLS(vc *vapi.Config) error { + if vc.HttpClient == nil { + vc.HttpClient = vapi.DefaultConfig().HttpClient + } + clientTLSConfig := vc.HttpClient.Transport.(*http.Transport).TLSClientConfig + + var clientCert tls.Certificate + foundClientCert := false + + switch { + case c.clientParams.ClientCertPath != "" && c.clientParams.ClientKeyPath != "": + c, err := tls.LoadX509KeyPair(c.clientParams.ClientCertPath, c.clientParams.ClientKeyPath) + if err != nil { + return status.Errorf(codes.InvalidArgument, "failed to parse client cert and private-key: %v", err) + } + clientCert = c + foundClientCert = true + case c.clientParams.ClientCertPath != "" || c.clientParams.ClientKeyPath != "": + return status.Error(codes.InvalidArgument, "both client cert and client key are required") + } + + if c.clientParams.CACertPath != "" { + certs, err := pemutil.LoadCertificates(c.clientParams.CACertPath) + if err != nil { + return status.Errorf(codes.InvalidArgument, "failed to load CA certificate: %v", err) + } + pool := x509.NewCertPool() + for _, cert := range certs { + pool.AddCert(cert) + } + clientTLSConfig.RootCAs = pool + } + + if c.clientParams.TLSSKipVerify { + clientTLSConfig.InsecureSkipVerify = true + } + + if foundClientCert { + clientTLSConfig.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &clientCert, nil + } + } + + return nil +} + +// SetToken wraps vapi.Client.SetToken() +func (c *Client) SetToken(v string) { + c.vaultClient.SetToken(v) +} + +// Auth authenticates to vault server with TLS certificate method +func (c *Client) Auth(path string, body map[string]any) (*vapi.Secret, error) { + c.vaultClient.ClearToken() + secret, err := c.vaultClient.Logical().Write(path, body) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "authentication failed %v: %v", path, err) + } + + tokenID, err := secret.TokenID() + if err != nil { + return nil, status.Errorf(codes.Internal, "authentication is successful, but could not get token: %v", err) + } + c.vaultClient.SetToken(tokenID) + return secret, nil +} + +func (c *Client) LookupSelf(token string) (*vapi.Secret, error) { + if token == "" { + return nil, status.Error(codes.InvalidArgument, "token is empty") + } + c.SetToken(token) + + secret, err := c.vaultClient.Logical().Read("auth/token/lookup-self") + if err != nil { + return nil, status.Errorf(codes.Internal, "token lookup failed: %v", err) + } + + id, err := secret.TokenID() + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to get TokenID: %v", err) + } + renewable, err := secret.TokenIsRenewable() + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to determine if token is renewable: %v", err) + } + ttl, err := secret.TokenTTL() + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to get token ttl: %v", err) + } + secret.Auth = &vapi.SecretAuth{ + ClientToken: id, + Renewable: renewable, + LeaseDuration: int(ttl.Seconds()), + // don't care any parameters + } + return secret, nil +} + +type TransitKeyType string + +const ( + TransitKeyTypeRSA2048 TransitKeyType = "rsa-2048" + TransitKeyTypeRSA4096 TransitKeyType = "rsa-4096" + TransitKeyTypeECDSAP256 TransitKeyType = "ecdsa-p256" + TransitKeyTypeECDSAP384 TransitKeyType = "ecdsa-p384" +) + +type TransitHashAlgorithm string + +const ( + TransitHashAlgorithmSHA256 TransitHashAlgorithm = "sha2-256" + TransitHashAlgorithmSHA384 TransitHashAlgorithm = "sha2-384" + TransitHashAlgorithmSHA512 TransitHashAlgorithm = "sha2-512" + TransitHashAlgorithmNone TransitHashAlgorithm = "none" +) + +type TransitSignatureAlgorithm string + +const ( + TransitSignatureSignatureAlgorithmPSS TransitSignatureAlgorithm = "pss" + TransitSignatureSignatureAlgorithmPKCS1v15 TransitSignatureAlgorithm = "pkcs1v15" +) + +// CreateKey creates a new key in the specified transit secret engine +// See: https://developer.hashicorp.com/vault/api-docs/secret/transit#create-key +func (c *Client) CreateKey(ctx context.Context, spireKeyID string, keyType TransitKeyType) error { + arguments := map[string]interface{}{ + "type": keyType, + "exportable": "false", // TODO: Maybe make this configurable + } + + _, err := c.vaultClient.Logical().WriteWithContext(ctx, fmt.Sprintf("/%s/keys/%s", c.clientParams.TransitEnginePath, spireKeyID), arguments) + if err != nil { + return status.Errorf(codes.Internal, "failed to create transit engine key: %v", err) + } + + return nil +} + +// SignData signs the data using the transit engine key with the provided spire key id. +// See: https://developer.hashicorp.com/vault/api-docs/secret/transit#sign-data +func (c *Client) SignData(ctx context.Context, spireKeyID string, data []byte, hashAlgo TransitHashAlgorithm, signatureAlgo TransitSignatureAlgorithm) ([]byte, error) { + encodedData := base64.StdEncoding.EncodeToString(data) + + body := map[string]interface{}{ + "input": encodedData, + "signature_algorithm": signatureAlgo, + "marshalling_algorithm": "asn1", + "prehashed": "true", + } + + sigResp, err := c.vaultClient.Logical().WriteWithContext(ctx, fmt.Sprintf("/%s/sign/%s/%s", c.clientParams.TransitEnginePath, spireKeyID, hashAlgo), body) + if err != nil { + return nil, status.Errorf(codes.Internal, "transit engine sign call failed: %v", err) + } + + sig, ok := sigResp.Data["signature"] + if !ok { + return nil, status.Errorf(codes.Internal, "transit engine sign call was successful but signature is missing") + } + + sigStr, ok := sig.(string) + if !ok { + return nil, status.Errorf(codes.Internal, "expected signature data type %T but got %T", sigStr, sig) + } + + // Vault adds an application specific prefix that we need to remove + cutSig, ok := strings.CutPrefix(sigStr, "vault:v1:") + if !ok { + return nil, status.Errorf(codes.Internal, "signature is missing vault prefix: %v", err) + } + + sigData, err := base64.StdEncoding.DecodeString(cutSig) + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to base64 decode signature: %v", err) + } + + return sigData, nil +} + +// GetKeys returns all the keys of the transit engine. +// See: https://developer.hashicorp.com/vault/api-docs/secret/transit#list-keys +func (c *Client) GetKeys(ctx context.Context) ([]*keyEntry, error) { + var keyEntries []*keyEntry + + listResp, err := c.vaultClient.Logical().ListWithContext(ctx, fmt.Sprintf("/%s/keys", c.clientParams.TransitEnginePath)) + if err != nil { + return nil, status.Errorf(codes.Internal, "transit engine list keys call failed: %v", err) + } + + if listResp == nil { + return []*keyEntry{}, nil + } + + keys, ok := listResp.Data["keys"] + if !ok { + return nil, status.Errorf(codes.Internal, "transit engine list keys call was successful but keys are missing") + } + + keyIds, ok := keys.([]interface{}) + if !ok { + return nil, status.Errorf(codes.Internal, "expected keys data type %T but got %T", keyIds, keys) + } + + for _, keyId := range keyIds { + keyIdStr, ok := keyId.(string) + if !ok { + return nil, status.Errorf(codes.Internal, "expected key id data type %T but got %T", keyIdStr, keyId) + } + + keyEntry, err := c.getKeyEntry(ctx, keyIdStr) + if err != nil { + return nil, err + } + + keyEntries = append(keyEntries, keyEntry) + } + + return keyEntries, nil +} + +// getKeyEntry gets the transit engine key with the specified spire key id and converts it into a key entry. +func (c *Client) getKeyEntry(ctx context.Context, spireKeyID string) (*keyEntry, error) { + keyData, err := c.getKey(ctx, spireKeyID) + if err != nil { + return nil, err + } + + pk, ok := keyData["public_key"] + if !ok { + return nil, status.Errorf(codes.Internal, "expected public key to be present") + } + + pkStr, ok := pk.(string) + if !ok { + return nil, status.Errorf(codes.Internal, "expected public key data type %T but got %T", pkStr, pk) + } + + pemBlock, _ := pem.Decode([]byte(pkStr)) + if pemBlock == nil || pemBlock.Type != "PUBLIC KEY" { + return nil, status.Error(codes.Internal, "unable to decode PEM key") + } + + pubKeyType, ok := keyData["name"] + if !ok { + return nil, status.Errorf(codes.Internal, "expected name to be present") + } + + pubKeyTypeStr, ok := pubKeyType.(string) + if !ok { + return nil, status.Errorf(codes.Internal, "expected public key type to be of type %T but got %T", pubKeyTypeStr, pubKeyType) + } + + var keyType keymanagerv1.KeyType + + switch pubKeyTypeStr { + case "P-256": + keyType = keymanagerv1.KeyType_EC_P256 + case "P-384": + keyType = keymanagerv1.KeyType_EC_P384 + case "rsa-2048": + keyType = keymanagerv1.KeyType_RSA_2048 + case "rsa-4096": + keyType = keymanagerv1.KeyType_RSA_4096 + default: + return nil, status.Errorf(codes.Internal, "unsupported key type: %v", pubKeyTypeStr) + } + + return &keyEntry{ + PublicKey: &keymanagerv1.PublicKey{ + Id: spireKeyID, + Type: keyType, + PkixData: pemBlock.Bytes, + Fingerprint: makeFingerprint(pemBlock.Bytes), + }, + }, nil +} + +// getKey returns a specific key from the transit engine. +// See: https://developer.hashicorp.com/vault/api-docs/secret/transit#read-key +func (c *Client) getKey(ctx context.Context, spireKeyID string) (map[string]interface{}, error) { + res, err := c.vaultClient.Logical().ReadWithContext(ctx, fmt.Sprintf("/%s/keys/%s", c.clientParams.TransitEnginePath, spireKeyID)) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get transit engine key: %v", err) + } + + keys, ok := res.Data["keys"] + if !ok { + return nil, status.Errorf(codes.Internal, "transit engine get key call was successful but keys are missing") + } + + keyMap, ok := keys.(map[string]interface{}) + if !ok { + return nil, status.Errorf(codes.Internal, "expected key map data type %T but got %T", keyMap, keys) + } + + // TODO: Should we support multiple versions of the key? + currentKey, ok := keyMap["1"] + if !ok { + return nil, status.Errorf(codes.Internal, "unable to find key with version 1 in %v", keyMap) + } + + currentKeyMap, ok := currentKey.(map[string]interface{}) + if !ok { + return nil, status.Errorf(codes.Internal, "expected key data type %T but got %T", currentKeyMap, currentKey) + } + + return currentKeyMap, nil +} diff --git a/pkg/server/plugin/keymanager/hashicorpvault/vault_client_test.go b/pkg/server/plugin/keymanager/hashicorpvault/vault_client_test.go new file mode 100644 index 0000000000..a6bc3560f0 --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/vault_client_test.go @@ -0,0 +1,1059 @@ +package hashicorpvault + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + vapi "github.com/hashicorp/vault/api" + keymanagerv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/keymanager/v1" + "net/http" + "os" + "testing" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/spiffe/spire/test/spiretest" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +const ( + testRootCert = "testdata/root-cert.pem" + testInvalidRootCert = "testdata/invalid-root-cert.pem" + testServerCert = "testdata/server-cert.pem" + testServerKey = "testdata/server-key.pem" + testClientCert = "testdata/client-cert.pem" + testInvalidClientCert = "testdata/invalid-client-cert.pem" + testClientKey = "testdata/client-key.pem" + testInvalidClientKey = "testdata/invalid-client-key.pem" +) + +func TestNewClientConfigWithDefaultValues(t *testing.T) { + p := &ClientParams{ + VaultAddr: "http://example.org:8200/", + PKIMountPoint: "", // Expect the default value to be used. + Token: "test-token", + CertAuthMountPoint: "", // Expect the default value to be used. + AppRoleAuthMountPoint: "", // Expect the default value to be used. + K8sAuthMountPoint: "", // Expect the default value to be used. + TransitEnginePath: "", // Expect the default value to be used. + } + + cc, err := NewClientConfig(p, hclog.Default()) + require.NoError(t, err) + require.Equal(t, defaultPKIMountPoint, cc.clientParams.PKIMountPoint) + require.Equal(t, defaultCertMountPoint, cc.clientParams.CertAuthMountPoint) + require.Equal(t, defaultAppRoleMountPoint, cc.clientParams.AppRoleAuthMountPoint) + require.Equal(t, defaultK8sMountPoint, cc.clientParams.K8sAuthMountPoint) + require.Equal(t, defaultTransitEnginePath, cc.clientParams.TransitEnginePath) +} + +func TestNewClientConfigWithGivenValuesInsteadOfDefaults(t *testing.T) { + p := &ClientParams{ + VaultAddr: "http://example.org:8200/", + PKIMountPoint: "test-pki", + Token: "test-token", + CertAuthMountPoint: "test-tls-cert", + AppRoleAuthMountPoint: "test-approle", + K8sAuthMountPoint: "test-k8s", + TransitEnginePath: "test-transit", + } + + cc, err := NewClientConfig(p, hclog.Default()) + require.NoError(t, err) + require.Equal(t, "test-pki", cc.clientParams.PKIMountPoint) + require.Equal(t, "test-tls-cert", cc.clientParams.CertAuthMountPoint) + require.Equal(t, "test-approle", cc.clientParams.AppRoleAuthMountPoint) + require.Equal(t, "test-k8s", cc.clientParams.K8sAuthMountPoint) + require.Equal(t, "test-transit", cc.clientParams.TransitEnginePath) +} + +func TestNewAuthenticatedClientTokenAuth(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.LookupSelfResponseCode = 200 + for _, tt := range []struct { + name string + token string + response []byte + renew bool + namespace string + expectCode codes.Code + expectMsgPrefix string + }{ + { + name: "Token Authentication success / Token never expire", + token: "test-token", + response: []byte(testLookupSelfResponseNeverExpire), + renew: true, + }, + { + name: "Token Authentication success / Token is renewable", + token: "test-token", + response: []byte(testLookupSelfResponse), + renew: true, + }, + { + name: "Token Authentication success / Token is not renewable", + token: "test-token", + response: []byte(testLookupSelfResponseNotRenewable), + }, + { + name: "Token Authentication success / Token is renewable / Namespace is given", + token: "test-token", + response: []byte(testCertAuthResponse), + renew: true, + namespace: "test-ns", + }, + { + name: "Token Authentication error / Token is empty", + token: "", + response: []byte(testCertAuthResponse), + renew: true, + namespace: "test-ns", + expectCode: codes.InvalidArgument, + expectMsgPrefix: "token is empty", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer.LookupSelfResponse = tt.response + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + Namespace: tt.namespace, + CACertPath: testRootCert, + Token: tt.token, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(TOKEN, renewCh) + if tt.expectMsgPrefix != "" { + spiretest.RequireGRPCStatusHasPrefix(t, err, tt.expectCode, tt.expectMsgPrefix) + return + } + + require.NoError(t, err) + + select { + case <-renewCh: + require.Equal(t, false, tt.renew) + default: + require.Equal(t, true, tt.renew) + } + + if cp.Namespace != "" { + headers := client.vaultClient.Headers() + require.Equal(t, cp.Namespace, headers.Get(consts.NamespaceHeaderName)) + } + }) + } +} + +func TestNewAuthenticatedClientAppRoleAuth(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.AppRoleAuthResponseCode = 200 + for _, tt := range []struct { + name string + response []byte + renew bool + namespace string + }{ + { + name: "AppRole Authentication success / Token is renewable", + response: []byte(testAppRoleAuthResponse), + renew: true, + }, + { + name: "AppRole Authentication success / Token is not renewable", + response: []byte(testAppRoleAuthResponseNotRenewable), + }, + { + name: "AppRole Authentication success / Token is renewable / Namespace is given", + response: []byte(testAppRoleAuthResponse), + renew: true, + namespace: "test-ns", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer.AppRoleAuthResponse = tt.response + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + Namespace: tt.namespace, + CACertPath: testRootCert, + AppRoleID: "test-approle-id", + AppRoleSecretID: "test-approle-secret-id", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(APPROLE, renewCh) + require.NoError(t, err) + + select { + case <-renewCh: + require.Equal(t, false, tt.renew) + default: + require.Equal(t, true, tt.renew) + } + + if cp.Namespace != "" { + headers := client.vaultClient.Headers() + require.Equal(t, cp.Namespace, headers.Get(consts.NamespaceHeaderName)) + } + }) + } +} + +func TestNewAuthenticatedClientAppRoleAuthFailed(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.AppRoleAuthResponseCode = 500 + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + AppRoleID: "test-approle-id", + AppRoleSecretID: "test-approle-secret-id", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + _, err = cc.NewAuthenticatedClient(APPROLE, renewCh) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Unauthenticated, "authentication failed auth/approle/login: Error making API request.") +} + +func TestNewAuthenticatedClientCertAuth(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + for _, tt := range []struct { + name string + response []byte + renew bool + namespace string + }{ + { + name: "Cert Authentication success / Token is renewable", + response: []byte(testCertAuthResponse), + renew: true, + }, + { + name: "Cert Authentication success / Token is not renewable", + response: []byte(testCertAuthResponseNotRenewable), + }, + { + name: "Cert Authentication success / Token is renewable / Namespace is given", + response: []byte(testCertAuthResponse), + renew: true, + namespace: "test-ns", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer.CertAuthResponse = tt.response + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + Namespace: tt.namespace, + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + select { + case <-renewCh: + require.Equal(t, false, tt.renew) + default: + require.Equal(t, true, tt.renew) + } + + if cp.Namespace != "" { + headers := client.vaultClient.Headers() + require.Equal(t, cp.Namespace, headers.Get(consts.NamespaceHeaderName)) + } + }) + } +} + +func TestNewAuthenticatedClientCertAuthFailed(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 500 + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + _, err = cc.NewAuthenticatedClient(CERT, renewCh) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Unauthenticated, "authentication failed auth/cert/login: Error making API request.") +} + +func TestNewAuthenticatedClientK8sAuth(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.K8sAuthResponseCode = 200 + for _, tt := range []struct { + name string + response []byte + renew bool + namespace string + }{ + { + name: "K8s Authentication success / Token is renewable", + response: []byte(testK8sAuthResponse), + renew: true, + }, + { + name: "K8s Authentication success / Token is not renewable", + response: []byte(testK8sAuthResponseNotRenewable), + }, + { + name: "K8s Authentication success / Token is renewable / Namespace is given", + response: []byte(testK8sAuthResponse), + renew: true, + namespace: "test-ns", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + fakeVaultServer.K8sAuthResponse = tt.response + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + Namespace: tt.namespace, + CACertPath: testRootCert, + K8sAuthRoleName: "my-role", + K8sAuthTokenPath: "testdata/k8s/token", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(K8S, renewCh) + require.NoError(t, err) + + select { + case <-renewCh: + require.Equal(t, false, tt.renew) + default: + require.Equal(t, true, tt.renew) + } + + if cp.Namespace != "" { + headers := client.vaultClient.Headers() + require.Equal(t, cp.Namespace, headers.Get(consts.NamespaceHeaderName)) + } + }) + } +} + +func TestNewAuthenticatedClientK8sAuthFailed(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.K8sAuthResponseCode = 500 + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + K8sAuthRoleName: "my-role", + K8sAuthTokenPath: "testdata/k8s/token", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + _, err = cc.NewAuthenticatedClient(K8S, renewCh) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Unauthenticated, "authentication failed auth/kubernetes/login: Error making API request.") +} + +func TestNewAuthenticatedClientK8sAuthInvalidPath(t *testing.T) { + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: "https://example.org:8200", + CACertPath: testRootCert, + K8sAuthTokenPath: "invalid/k8s/token", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + _, err = cc.NewAuthenticatedClient(K8S, renewCh) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "failed to read k8s service account token:") +} + +func TestRenewTokenFailed(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.LookupSelfResponse = []byte(testLookupSelfResponseShortTTL) + fakeVaultServer.LookupSelfResponseCode = 200 + fakeVaultServer.RenewResponse = []byte("fake renew error") + fakeVaultServer.RenewResponseCode = 500 + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + Token: "test-token", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + _, err = cc.NewAuthenticatedClient(TOKEN, renewCh) + require.NoError(t, err) + + select { + case <-renewCh: + case <-time.After(1 * time.Second): + t.Error("renewChan did not close in the expected time") + } +} + +func TestConfigureTLSWithCertAuth(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + CACertPath: testRootCert, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + require.NoError(t, err) + + tcc := vc.HttpClient.Transport.(*http.Transport).TLSClientConfig + cert, err := tcc.GetClientCertificate(&tls.CertificateRequestInfo{}) + require.NoError(t, err) + + testCert, err := testClientCertificatePair() + require.NoError(t, err) + require.Equal(t, testCert.Certificate, cert.Certificate) + + testPool, err := testRootCAs() + require.NoError(t, err) + require.True(t, testPool.Equal(tcc.RootCAs)) +} + +func TestConfigureTLSWithTokenAuth(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + CACertPath: testRootCert, + Token: "test-token", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + require.NoError(t, err) + + tcc := vc.HttpClient.Transport.(*http.Transport).TLSClientConfig + require.Nil(t, tcc.GetClientCertificate) + + testPool, err := testRootCAs() + require.NoError(t, err) + require.Equal(t, testPool.Subjects(), tcc.RootCAs.Subjects()) //nolint:staticcheck // these pools are not system pools so the use of Subjects() is ok for now +} + +func TestConfigureTLSWithAppRoleAuth(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + CACertPath: testRootCert, + AppRoleID: "test-approle-id", + AppRoleSecretID: "test-approle-secret", + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + require.NoError(t, err) + + tcc := vc.HttpClient.Transport.(*http.Transport).TLSClientConfig + require.Nil(t, tcc.GetClientCertificate) + + testPool, err := testRootCAs() + require.NoError(t, err) + require.Equal(t, testPool.Subjects(), tcc.RootCAs.Subjects()) //nolint:staticcheck // these pools are not system pools so the use of Subjects() is ok for now +} + +func TestConfigureTLSInvalidCACert(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + CACertPath: testInvalidRootCert, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.InvalidArgument, "failed to load CA certificate: no PEM blocks") +} + +func TestConfigureTLSInvalidClientKey(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + ClientCertPath: testClientCert, + ClientKeyPath: testInvalidClientKey, + CACertPath: testRootCert, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.InvalidArgument, "failed to parse client cert and private-key: tls: failed to find any PEM data in key input") +} + +func TestConfigureTLSInvalidClientCert(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + ClientCertPath: testInvalidClientCert, + ClientKeyPath: testClientKey, + CACertPath: testRootCert, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.InvalidArgument, "failed to parse client cert and private-key: tls: failed to find any PEM data in certificate input") +} + +func TestConfigureTLSRequireClientCertAndKey(t *testing.T) { + cp := &ClientParams{ + VaultAddr: "http://example.org:8200", + ClientCertPath: testClientCert, + CACertPath: testRootCert, + } + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + vc := vapi.DefaultConfig() + err = cc.configureTLS(vc) + spiretest.RequireGRPCStatus(t, err, codes.InvalidArgument, "both client cert and client key are required") +} + +func TestCreateKey(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.CreateKeyResponseCode = 204 + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + err = client.CreateKey(context.Background(), "x509-CA-A", TransitKeyTypeRSA2048) + require.NoError(t, err) +} + +func TestCreateKeyErrorFromEndpoint(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.CreateKeyResponseCode = 500 + fakeVaultServer.CreateKeyResponse = []byte("test error") + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + err = client.CreateKey(context.Background(), "x509-CA-A", TransitKeyTypeRSA2048) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "failed to create transit engine key: Error making API request.") +} + +func TestGetKeysSingleKey(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeysResponseCode = 200 + fakeVaultServer.GetKeysResponse = []byte(testGetKeysResponseOneKey) + fakeVaultServer.GetKeyResponseCode = 200 + fakeVaultServer.GetKeyResponse = []byte(testGetKeyResponseP256) + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.GetKeys(context.Background()) + require.NoError(t, err) + + block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV57LFbIQZzyZ2YcKZfB9mGWkUhJv\niRzIZOqV4wRHoUOZjMuhBMR2WviEsy65TYpcBjreAc6pbneiyhlTwPvgmw==\n-----END PUBLIC KEY-----\n")) + + require.Len(t, resp, 1) + + require.Equal(t, "x509-CA-A", resp[0].PublicKey.Id) + require.Equal(t, keymanagerv1.KeyType_EC_P256, resp[0].PublicKey.Type) + require.Equal(t, block.Bytes, resp[0].PublicKey.PkixData) + require.Equal(t, "afd4e26c151ce5c1069414bdb08fe5f7a7fdb271d40d077aa1f77a82e8ac5870", resp[0].PublicKey.Fingerprint) +} + +func TestGetKeysNoKey(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeysResponseCode = 200 + fakeVaultServer.GetKeysResponse = []byte(testGetKeysResponseNoKeys) + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.GetKeys(context.Background()) + require.NoError(t, err) + + require.Empty(t, resp) +} + +func TestGetKeysErrorFromListEndpoint(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeysResponseCode = 500 + fakeVaultServer.GetKeysResponse = []byte("some error") + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.GetKeys(context.Background()) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "transit engine list keys call failed: Error making API request.") + require.Empty(t, resp) +} + +func TestGetKeysErrorFromKeyEndpoint(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeysResponseCode = 200 + fakeVaultServer.GetKeysResponse = []byte(testGetKeysResponseOneKey) + fakeVaultServer.GetKeyResponseCode = 500 + fakeVaultServer.GetKeyResponse = []byte("some error") + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.GetKeys(context.Background()) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "failed to get transit engine key: Error making API request.") + require.Empty(t, resp) +} + +func TestGetKey(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeyResponseCode = 200 + fakeVaultServer.GetKeyResponse = []byte(testGetKeyResponseP256) + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.getKey(context.Background(), "x509-CA-A") + require.NoError(t, err) + + require.Equal(t, map[string]interface{}{ + "name": "P-256", + "creation_time": "2024-09-16T18:18:54.284635756Z", + "public_key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV57LFbIQZzyZ2YcKZfB9mGWkUhJv\niRzIZOqV4wRHoUOZjMuhBMR2WviEsy65TYpcBjreAc6pbneiyhlTwPvgmw==\n-----END PUBLIC KEY-----\n", + }, resp) +} + +func TestGetKeyErrorFromEndpoint(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeyResponseCode = 500 + fakeVaultServer.GetKeyResponse = []byte("test error") + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.getKey(context.Background(), "x509-CA-A") + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "failed to get transit engine key: Error making API request.") + require.Empty(t, resp) +} + +func TestGetKeyEntry(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeyResponseCode = 200 + fakeVaultServer.GetKeyResponse = []byte(testGetKeyResponseP256) + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.getKeyEntry(context.Background(), "x509-CA-A") + require.NoError(t, err) + + block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV57LFbIQZzyZ2YcKZfB9mGWkUhJv\niRzIZOqV4wRHoUOZjMuhBMR2WviEsy65TYpcBjreAc6pbneiyhlTwPvgmw==\n-----END PUBLIC KEY-----\n")) + + require.Equal(t, "x509-CA-A", resp.PublicKey.Id) + require.Equal(t, keymanagerv1.KeyType_EC_P256, resp.PublicKey.Type) + require.Equal(t, block.Bytes, resp.PublicKey.PkixData) + require.Equal(t, "afd4e26c151ce5c1069414bdb08fe5f7a7fdb271d40d077aa1f77a82e8ac5870", resp.PublicKey.Fingerprint) +} + +func TestGetKeyEntryErrorFromEndpoint(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.GetKeyResponseCode = 500 + fakeVaultServer.GetKeyResponse = []byte("some error") + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.getKeyEntry(context.Background(), "x509-CA-A") + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "failed to get transit engine key: Error making API request.") + require.Empty(t, resp) +} + +func TestSignData(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.SignDataResponseCode = 200 + fakeVaultServer.SignDataResponse = []byte(testSignDataResponse) + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + cp := &ClientParams{ + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.SignData(context.Background(), "x509-CA-A", []byte("foo"), TransitHashAlgorithmSHA256, TransitSignatureSignatureAlgorithmPKCS1v15) + require.NoError(t, err) + + expected, err := base64.StdEncoding.DecodeString("MEQCIHw3maFgxsmzAUsUXnw2ahUgPcomjF8+XxflwH4CsouhAiAYL3RhWx8dP2ymm7hjSUvc9EQ8GPXmLrvgacqkEKQPGw==") + require.NoError(t, err) + require.Equal(t, expected, resp) +} + +func TestSignDataErrorFromEndpoint(t *testing.T) { + fakeVaultServer := newFakeVaultServer() + fakeVaultServer.CertAuthResponseCode = 200 + fakeVaultServer.CertAuthResponse = []byte(testCertAuthResponse) + fakeVaultServer.SignDataResponseCode = 500 + fakeVaultServer.SignDataResponse = []byte("test error") + + s, addr, err := fakeVaultServer.NewTLSServer() + require.NoError(t, err) + + s.Start() + defer s.Close() + + retry := 0 // Disable retry + cp := &ClientParams{ + MaxRetries: &retry, + VaultAddr: fmt.Sprintf("https://%v/", addr), + CACertPath: testRootCert, + ClientCertPath: testClientCert, + ClientKeyPath: testClientKey, + } + + cc, err := NewClientConfig(cp, hclog.Default()) + require.NoError(t, err) + + renewCh := make(chan struct{}) + client, err := cc.NewAuthenticatedClient(CERT, renewCh) + require.NoError(t, err) + + resp, err := client.SignData(context.Background(), "x509-CA-A", []byte("foo"), TransitHashAlgorithmSHA256, TransitSignatureSignatureAlgorithmPKCS1v15) + spiretest.RequireGRPCStatusHasPrefix(t, err, codes.Internal, "transit engine sign call failed: Error making API request.") + require.Empty(t, resp) +} + +func newFakeVaultServer() *FakeVaultServerConfig { + fakeVaultServer := NewFakeVaultServerConfig() + fakeVaultServer.RenewResponseCode = 200 + fakeVaultServer.RenewResponse = []byte(testRenewResponse) + return fakeVaultServer +} + +func testClientCertificatePair() (tls.Certificate, error) { + cert, err := os.ReadFile(testClientCert) + if err != nil { + return tls.Certificate{}, err + } + key, err := os.ReadFile(testClientKey) + if err != nil { + return tls.Certificate{}, err + } + + return tls.X509KeyPair(cert, key) +} + +func testRootCAs() (*x509.CertPool, error) { + pool := x509.NewCertPool() + pem, err := os.ReadFile(testRootCert) + if err != nil { + return nil, err + } + ok := pool.AppendCertsFromPEM(pem) + if !ok { + return nil, err + } + return pool, nil +} diff --git a/pkg/server/plugin/keymanager/hashicorpvault/vault_fake_test.go b/pkg/server/plugin/keymanager/hashicorpvault/vault_fake_test.go new file mode 100644 index 0000000000..6806cf0c7b --- /dev/null +++ b/pkg/server/plugin/keymanager/hashicorpvault/vault_fake_test.go @@ -0,0 +1,713 @@ +package hashicorpvault + +import ( + "crypto/tls" + "fmt" + "net/http" + "net/http/httptest" +) + +const ( + defaultTLSAuthEndpoint = "PUT /v1/auth/cert/login" + defaultAppRoleAuthEndpoint = "PUT /v1/auth/approle/login" + defaultK8sAuthEndpoint = "PUT /v1/auth/kubernetes/login" + defaultRenewEndpoint = "POST /v1/auth/token/renew-self" + defaultLookupSelfEndpoint = "GET /v1/auth/token/lookup-self" + defaultCreateKeyEndpoint = "PUT /v1/transit/keys/{id}" + defaultGetKeyEndpoint = "GET /v1/transit/keys/{id}" + defaultGetKeysEndpoint = "GET /v1/transit/keys" + defaultSignDataEndpoint = "PUT /v1/transit/sign/{id}/{algo}" + + listenAddr = "127.0.0.1:0" +) + +var ( + testTokenAuthConfigTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +token_auth { + token = "test-token" +}` + + testTokenAuthConfigWithEnvTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +token_auth {}` + + testCertAuthConfigTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +cert_auth { + cert_auth_mount_point = "test-cert-auth" + cert_auth_role_name = "test" + client_cert_path = "testdata/client-cert.pem" + client_key_path = "testdata/client-key.pem" +}` + + testCertAuthConfigWithEnvTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +cert_auth { + cert_auth_mount_point = "test-cert-auth" +}` + + testAppRoleAuthConfigTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +approle_auth { + approle_auth_mount_point = "test-approle-auth" + approle_id = "test-approle-id" + approle_secret_id = "test-approle-secret-id" +}` + + testAppRoleAuthConfigWithEnvTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +approle_auth { + approle_auth_mount_point = "test-approle-auth" +}` + + testK8sAuthConfigTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +k8s_auth { + k8s_auth_mount_point = "test-k8s-auth" + k8s_auth_role_name = "my-role" + token_path = "testdata/k8s/token" +}` + + testMultipleAuthConfigsTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +cert_auth {} +token_auth {} +approle_auth { + approle_auth_mount_point = "test-approle-auth" + approle_id = "test-approle-id" + approle_secret_id = "test-approle-secret-id" +}` + + testConfigWithVaultAddrEnvTpl = ` +ca_cert_path = "testdata/root-cert.pem" +token_auth { + token = "test-token" +}` + + testConfigWithTransitEnginePathTpl = ` +transit_engine_path = "test-path" +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +token_auth { + token = "test-token" +}` + + testConfigWithTransitEnginePathEnvTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +token_auth { + token = "test-token" +}` + + testNamespaceConfigTpl = ` +namespace = "test-ns" +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +token_auth { + token = "test-token" +}` + + testNamespaceEnvTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +token_auth { + token = "test-token" +}` + + testK8sAuthNoRoleNameTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +k8s_auth { + k8s_auth_mount_point = "test-k8s-auth" + token_path = "testdata/k8s/token" +}` + + testK8sAuthNoTokenPathTpl = ` +vault_addr = "{{ .Addr }}" +ca_cert_path = "testdata/root-cert.pem" +k8s_auth { + k8s_auth_mount_point = "test-k8s-auth" + k8s_auth_role_name = "my-role" +}` + + testCertAuthResponse = `{ + "auth": { + "client_token": "cf95f87d-f95b-47ff-b1f5-ba7bff850425", + "policies": [ + "web", + "stage" + ], + "lease_duration": 3600, + "renewable": true + } +}` + + testAppRoleAuthResponse = `{ + "auth": { + "renewable": true, + "lease_duration": 1200, + "metadata": null, + "token_policies": [ + "default" + ], + "accessor": "fd6c9a00-d2dc-3b11-0be5-af7ae0e1d374", + "client_token": "5b1a0318-679c-9c45-e5c6-d1b9a9035d49" + }, + "warnings": null, + "wrap_info": null, + "data": null, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +}` + + testAppRoleAuthResponseNotRenewable = `{ + "auth": { + "renewable": false, + "lease_duration": 3600, + "metadata": null, + "token_policies": [ + "default" + ], + "accessor": "fd6c9a00-d2dc-3b11-0be5-af7ae0e1d374", + "client_token": "5b1a0318-679c-9c45-e5c6-d1b9a9035d49" + }, + "warnings": null, + "wrap_info": null, + "data": null, + "lease_duration": 0, + "renewable": false, + "lease_id": "" +}` + + testRenewResponse = `{ + "auth": { + "client_token": "test-client-token", + "policies": ["app", "test"], + "metadata": { + "user": "test" + }, + "lease_duration": 3600, + "renewable": true + } +}` + + testLookupSelfResponseNeverExpire = `{ + "request_id": "90e4b86a-5c61-1aeb-0fc7-50a05056c3b3", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": { + "accessor": "rQuZeGOEdH4IazavJWqwTCRk", + "creation_time": 1605502335, + "creation_ttl": 0, + "display_name": "root", + "entity_id": "", + "expire_time": null, + "explicit_max_ttl": 0, + "id": "test-token", + "meta": null, + "num_uses": 0, + "orphan": true, + "path": "auth/token/root", + "policies": [ + "root" + ], + "ttl": 0, + "type": "service" + }, + "warnings": null +}` + + testLookupSelfResponse = `{ + "request_id": "8dc10d02-797d-1c23-f9f3-c7f07be89150", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": { + "accessor": "sB3mNrjoIr2JscfNsAUM1k0A", + "creation_time": 1605502988, + "creation_ttl": 2764800, + "display_name": "approle", + "entity_id": "0bee5a2d-efe5-6fd3-9c5a-972266ecccf4", + "expire_time": "2020-12-18T05:03:08.5694729Z", + "explicit_max_ttl": 0, + "id": "test-token", + "issue_time": "2020-11-16T05:03:08.5694807Z", + "meta": { + "role_name": "test" + }, + "num_uses": 0, + "orphan": true, + "path": "auth/approle/login", + "policies": [ + "default" + ], + "renewable": true, + "ttl": 3600, + "type": "service" + }, + "warnings": null +}` + + testLookupSelfResponseShortTTL = `{ + "request_id": "8dc10d02-797d-1c23-f9f3-c7f07be89150", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": { + "accessor": "sB3mNrjoIr2JscfNsAUM1k0A", + "creation_time": 1605502988, + "creation_ttl": 2764800, + "display_name": "approle", + "entity_id": "0bee5a2d-efe5-6fd3-9c5a-972266ecccf4", + "expire_time": "2020-12-18T05:03:08.5694729Z", + "explicit_max_ttl": 0, + "id": "test-token", + "issue_time": "2020-11-16T05:03:08.5694807Z", + "meta": { + "role_name": "test" + }, + "num_uses": 0, + "orphan": true, + "path": "auth/approle/login", + "policies": [ + "default" + ], + "renewable": true, + "ttl": 1, + "type": "service" + }, + "warnings": null +}` + + testLookupSelfResponseNotRenewable = `{ + "request_id": "ac39fad7-02d7-48df-2f8a-7a1872c41a4b", + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "data": { + "accessor": "", + "creation_time": 1605506361, + "creation_ttl": 3600, + "display_name": "approle", + "entity_id": "0bee5a2d-efe5-6fd3-9c5a-972266ecccf4", + "expire_time": "2020-11-16T06:59:21Z", + "explicit_max_ttl": 0, + "id": "test-token", + "issue_time": "2020-11-16T05:59:21Z", + "meta": { + "role_name": "test" + }, + "num_uses": 0, + "orphan": true, + "path": "auth/approle/login", + "policies": [ + "default" + ], + "renewable": false, + "ttl": 3517, + "type": "batch" + }, + "warnings": null +}` + + testCertAuthResponseNotRenewable = `{ + "auth": { + "client_token": "cf95f87d-f95b-47ff-b1f5-ba7bff850425", + "policies": [ + "web", + "stage" + ], + "lease_duration": 3600, + "renewable": false + } +}` + + testK8sAuthResponse = `{ + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": null, + "wrap_info": null, + "warnings": null, + "auth": { + "client_token": "s.scngmDktKCWVRhkggMiyV7E7", + "accessor": "", + "policies": ["default"], + "token_policies": ["default"], + "metadata": { + "role": "my-role", + "service_account_name": "spire-server", + "service_account_namespace": "spire", + "service_account_secret_name": "", + "service_account_uid": "6808b4c7-0b53-45f4-83f7-e8937756eeae" + }, + "lease_duration": 3600, + "renewable": true, + "entity_id": "c69a6e0e-3f2c-98a0-39f9-e4d3d7cc294f", + "token_type": "service", + "orphan": true + } +} +` + + testK8sAuthResponseNotRenewable = `{ + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": null, + "wrap_info": null, + "warnings": null, + "auth": { + "client_token": "b.AAAAAQIUprvfquccAKnvL....", + "accessor": "", + "policies": ["default"], + "token_policies": ["default"], + "metadata": { + "role": "my-role", + "service_account_name": "spire-server", + "service_account_namespace": "spire", + "service_account_secret_name": "", + "service_account_uid": "6808b4c7-0b53-45f4-83f7-e8937756eeae" + }, + "lease_duration": 3600, + "renewable": false, + "entity_id": "c69a6e0e-3f2c-98a0-39f9-e4d3d7cc294f", + "token_type": "batch", + "orphan": true + } +}` + + testGetKeysResponseOneKey = `{ + "request_id": "3d02d2cf-baa4-a4ca-90d8-448b6c3ce6b0", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "keys": [ + "x509-CA-A" + ] + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testGetKeysResponseNoKeys = `{ + "request_id": "3d02d2cf-baa4-a4ca-90d8-448b6c3ce6b0", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "keys": [] + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testGetKeyResponseP256 = `{ + "request_id": "646eddbd-83fd-0cc1-387b-f1a17fa88c3d", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": { + "creation_time": "2024-09-16T18:18:54.284635756Z", + "name": "P-256", + "public_key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEV57LFbIQZzyZ2YcKZfB9mGWkUhJv\niRzIZOqV4wRHoUOZjMuhBMR2WviEsy65TYpcBjreAc6pbneiyhlTwPvgmw==\n-----END PUBLIC KEY-----\n" + } + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "x509-CA-A", + "supports_decryption": false, + "supports_derivation": false, + "supports_encryption": false, + "supports_signing": true, + "type": "ecdsa-p256" + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testGetKeyResponseP384 = `{ + "request_id": "a97c3069-1369-dcbb-c687-a431f8d7f324", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": { + "creation_time": "2024-09-17T18:27:19.664989473Z", + "name": "P-384", + "public_key": "-----BEGIN PUBLIC KEY-----\nMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEXpDQLh6ct/CJuMV2UIXnm/GilDNgy6Qy\ngzGhGsRaGrlYtM8g3sSHoGBIR+wT2hIF0ryY4mqYPtzw39WiHSdK3J985iX/bMXD\npr5xe142+1uHbJdKfSD5LrycBBtIsoEH\n-----END PUBLIC KEY-----\n" + } + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "x509-CA-A", + "supports_decryption": false, + "supports_derivation": false, + "supports_encryption": false, + "supports_signing": true, + "type": "ecdsa-p384" + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testGetKeyResponseRSA2048 = `{ + "request_id": "7a74d33f-2e4b-8f34-48ba-80ff1c0a447c", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": { + "creation_time": "2024-09-17T18:30:26.427076525Z", + "name": "rsa-2048", + "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnV4uS61DWBvfbpzuHzIQ\nRbPZfLbe5wolynACSBNB4DxskuAZOg27e9wKUVwg82gOFPM4t1mVMHYee2OqEspZ\n5zL6y5bfwK//F+H8B6egitPKcHIv6WtErCrl3NM7V8jv4JIxmSeLRFNLpsGPp2dc\nZ/Q/SwprFhMfBiskCmOf+FlOrLZXe7a6Wsfe2yTJIwC5zGn+jNPVBmscHqjzttME\n4/xoZxCg13uZa1rskIOW526RT7ccfIMo8qGoZ0KVjnAJGuTwhFvJ+D/jwhHDylsP\n1ngHgJlBnDo23GouQD13TRaRUamTb4sliRAFdrWwK3j9YaOgtJnBYikkG1T/SSsm\nMQIDAQAB\n-----END PUBLIC KEY-----\n" + } + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "x509-CA-A", + "supports_decryption": true, + "supports_derivation": false, + "supports_encryption": true, + "supports_signing": true, + "type": "rsa-2048" + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testGetKeyResponseRSA4096 = `{ + "request_id": "486c49b6-149a-7886-52c6-5d082d4329fc", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": { + "creation_time": "2024-09-17T18:34:21.286589438Z", + "name": "rsa-4096", + "public_key": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsmp4dJSfPGDGhmWoBD7G\nYPBQ/KGCR8/huy7/bjNRprKKpnhDl+4y5OaQVUqvFnoJZYfQvowcaGrARwBrsvPw\nkwPe6dB33XZBCWWDIvMORAQhgGeQF0MRjKibxDxlwPLZLARnHF8674gDdbL7Tg/G\nxQqThWNqVk6/GiHnAjkBntyw3V5XI5RtmpdSLDcZOUdqh/Bwi6fGOwtW1kU2NVSG\nalhdQu1O2Pr72sVZ/9+LwMYv1ZI0lFULwr7ZaIo86+vei4BIk+Pd/kkOjn9KKJD1\n84eL1QnN03XPc9ENCt7rF/R+IT7YkoqCDBZawW6VpexrA6QxtxUO0DcAffIFJ61Z\n9N7p3VULjZZIJmpOaMTEu3wFritcTBZweI3gikisg3YMqRDzC97+WqKUGpWUfGcF\ngENRvqIlE05snmmwziGB4Rey3yAqZBHSXRWFWKdDX/X7gMEJ4Av7hAumMxgR34If\ndzEShW6ushnOEtlXQR0/DE814GBWI0+oa+w9m20XkzL60bUIZevP9mOhbSNxuN8m\naCDOjIa7qeX3yg1l4+dnAZ/S8O+K3GEWkqWwq/FXH1EfCGeztp2b0pN8n0r0Tr3S\nHkHMNNEXovlQevgEFEc01Kg8PXBDd1hP31dfMfZ6v+BXygGHg95zR4AFpcRIYJWu\n9dmMkmMWQN5rZeyDO7ZfDQ0CAwEAAQ==\n-----END PUBLIC KEY-----\n" + } + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "x509-CA-A", + "supports_decryption": true, + "supports_derivation": false, + "supports_encryption": true, + "supports_signing": true, + "type": "rsa-4096" + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testGetKeyResponseMalformed = `{ + "request_id": "486c49b6-149a-7886-52c6-5d082d4329fc", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": { + "creation_time": "2024-09-17T18:34:21.286589438Z", + "name": "rsa-4096", + "public_key": "-----BEGIN MALFORMED KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsmp4dJSfPGDGhmWoBD7G\nYPBQ/KGCR8/huy7/bjNRprKKpnhDl+4y5OaQVUqvFnoJZYfQvowcaGrARwBrsvPw\nkwPe6dB33XZBCWWDIvMORAQhgGeQF0MRjKibxDxlwPLZLARnHF8674gDdbL7Tg/G\nxQqThWNqVk6/GiHnAjkBntyw3V5XI5RtmpdSLDcZOUdqh/Bwi6fGOwtW1kU2NVSG\nalhdQu1O2Pr72sVZ/9+LwMYv1ZI0lFULwr7ZaIo86+vei4BIk+Pd/kkOjn9KKJD1\n84eL1QnN03XPc9ENCt7rF/R+IT7YkoqCDBZawW6VpexrA6QxtxUO0DcAffIFJ61Z\n9N7p3VULjZZIJmpOaMTEu3wFritcTBZweI3gikisg3YMqRDzC97+WqKUGpWUfGcF\ngENRvqIlE05snmmwziGB4Rey3yAqZBHSXRWFWKdDX/X7gMEJ4Av7hAumMxgR34If\ndzEShW6ushnOEtlXQR0/DE814GBWI0+oa+w9m20XkzL60bUIZevP9mOhbSNxuN8m\naCDOjIa7qeX3yg1l4+dnAZ/S8O+K3GEWkqWwq/FXH1EfCGeztp2b0pN8n0r0Tr3S\nHkHMNNEXovlQevgEFEc01Kg8PXBDd1hP31dfMfZ6v+BXygGHg95zR4AFpcRIYJWu\n9dmMkmMWQN5rZeyDO7ZfDQ0CAwEAAQ==\n-----END MALFORMED KEY-----\n" + } + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "x509-CA-A", + "supports_decryption": true, + "supports_derivation": false, + "supports_encryption": true, + "supports_signing": true, + "type": "rsa-4096" + }, + "wrap_info": null, + "warnings": null, + "auth": null +}` + + testSignDataResponse = `{ + "request_id": "51bb98fa-8da3-8678-64e7-7220bc8b94a6", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "key_version": 1, + "signature": "vault:v1:MEQCIHw3maFgxsmzAUsUXnw2ahUgPcomjF8+XxflwH4CsouhAiAYL3RhWx8dP2ymm7hjSUvc9EQ8GPXmLrvgacqkEKQPGw==" + }, + "wrap_info": null, + "warnings": null, + "auth": null +} +` +) + +type FakeVaultServerConfig struct { + ListenAddr string + ServerCertificatePemPath string + ServerKeyPemPath string + CertAuthReqEndpoint string + CertAuthReqHandler func(code int, resp []byte) func(http.ResponseWriter, *http.Request) + CertAuthResponseCode int + CertAuthResponse []byte + AppRoleAuthReqEndpoint string + AppRoleAuthReqHandler func(code int, resp []byte) func(w http.ResponseWriter, r *http.Request) + AppRoleAuthResponseCode int + AppRoleAuthResponse []byte + K8sAuthReqEndpoint string + K8sAuthReqHandler func(code int, resp []byte) func(w http.ResponseWriter, r *http.Request) + K8sAuthResponseCode int + K8sAuthResponse []byte + RenewReqEndpoint string + RenewReqHandler func(code int, resp []byte) func(http.ResponseWriter, *http.Request) + RenewResponseCode int + RenewResponse []byte + LookupSelfReqEndpoint string + LookupSelfReqHandler func(code int, resp []byte) func(w http.ResponseWriter, r *http.Request) + LookupSelfResponseCode int + LookupSelfResponse []byte + CreateKeyReqEndpoint string + CreateKeyReqHandler func(code int, resp []byte) func(http.ResponseWriter, *http.Request) + CreateKeyResponseCode int + CreateKeyResponse []byte + GetKeyReqEndpoint string + GetKeyReqHandler func(code int, resp []byte) func(http.ResponseWriter, *http.Request) + GetKeyResponseCode int + GetKeyResponse []byte + GetKeysReqEndpoint string + GetKeysReqHandler func(code int, resp []byte) func(http.ResponseWriter, *http.Request) + GetKeysResponseCode int + GetKeysResponse []byte + SignDataReqEndpoint string + SignDataReqHandler func(code int, resp []byte) func(http.ResponseWriter, *http.Request) + SignDataResponseCode int + SignDataResponse []byte +} + +// NewFakeVaultServerConfig returns VaultServerConfig with default values +func NewFakeVaultServerConfig() *FakeVaultServerConfig { + return &FakeVaultServerConfig{ + ListenAddr: listenAddr, + CertAuthReqEndpoint: defaultTLSAuthEndpoint, + CertAuthReqHandler: defaultReqHandler, + AppRoleAuthReqEndpoint: defaultAppRoleAuthEndpoint, + AppRoleAuthReqHandler: defaultReqHandler, + K8sAuthReqEndpoint: defaultK8sAuthEndpoint, + K8sAuthReqHandler: defaultReqHandler, + RenewReqEndpoint: defaultRenewEndpoint, + RenewReqHandler: defaultReqHandler, + LookupSelfReqEndpoint: defaultLookupSelfEndpoint, + LookupSelfReqHandler: defaultReqHandler, + CreateKeyReqEndpoint: defaultCreateKeyEndpoint, + CreateKeyReqHandler: defaultReqHandler, + GetKeyReqEndpoint: defaultGetKeyEndpoint, + GetKeyReqHandler: defaultReqHandler, + GetKeysReqEndpoint: defaultGetKeysEndpoint, + GetKeysReqHandler: defaultReqHandler, + SignDataReqEndpoint: defaultSignDataEndpoint, + SignDataReqHandler: defaultReqHandler, + } +} + +func defaultReqHandler(code int, resp []byte) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + _, _ = w.Write(resp) + } +} + +func (v *FakeVaultServerConfig) NewTLSServer() (srv *httptest.Server, addr string, err error) { + cert, err := tls.LoadX509KeyPair(testServerCert, testServerKey) + if err != nil { + return nil, "", fmt.Errorf("failed to load key-pair: %w", err) + } + config := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + l, err := tls.Listen("tcp", v.ListenAddr, config) + if err != nil { + return nil, "", fmt.Errorf("failed to listen test server: %w", err) + } + + mux := http.NewServeMux() + mux.HandleFunc(v.CertAuthReqEndpoint, v.CertAuthReqHandler(v.CertAuthResponseCode, v.CertAuthResponse)) + mux.HandleFunc(v.AppRoleAuthReqEndpoint, v.AppRoleAuthReqHandler(v.AppRoleAuthResponseCode, v.AppRoleAuthResponse)) + mux.HandleFunc(v.K8sAuthReqEndpoint, v.AppRoleAuthReqHandler(v.K8sAuthResponseCode, v.K8sAuthResponse)) + mux.HandleFunc(v.RenewReqEndpoint, v.RenewReqHandler(v.RenewResponseCode, v.RenewResponse)) + mux.HandleFunc(v.LookupSelfReqEndpoint, v.LookupSelfReqHandler(v.LookupSelfResponseCode, v.LookupSelfResponse)) + mux.HandleFunc(v.CreateKeyReqEndpoint, v.CreateKeyReqHandler(v.CreateKeyResponseCode, v.CreateKeyResponse)) + mux.HandleFunc(v.GetKeyReqEndpoint, v.GetKeyReqHandler(v.GetKeyResponseCode, v.GetKeyResponse)) + mux.HandleFunc(v.GetKeysReqEndpoint, v.GetKeysReqHandler(v.GetKeysResponseCode, v.GetKeysResponse)) + mux.HandleFunc(v.SignDataReqEndpoint, v.SignDataReqHandler(v.SignDataResponseCode, v.SignDataResponse)) + + srv = httptest.NewUnstartedServer(mux) + srv.Listener = l + return srv, l.Addr().String(), nil +} diff --git a/test/integration/common b/test/integration/common index baf0b7d13f..eef46d56b9 100644 --- a/test/integration/common +++ b/test/integration/common @@ -224,7 +224,7 @@ download-kind() { elif [ "${ARCH}" = "aarch64" ]; then ARCH=arm64 fi - echo "Ensuring kind version $KINDVERSION is available..." + echo "Ensuring kind version $KINDVERSION is available..." KINDURL="https://github.com/kubernetes-sigs/kind/releases/download/$KINDVERSION/kind-$UNAME-$ARCH" local kind_link_or_path=$1 @@ -247,7 +247,7 @@ download-helm() { ARCH=arm64 fi - echo "Ensuring helm version $HELMVERSION is available..." + echo "Ensuring helm version $HELMVERSION is available..." HELMURL="https://get.helm.sh/helm-${HELMVERSION}-${UNAME}-${ARCH}.tar.gz" local helm_link_or_path=$1 @@ -274,7 +274,7 @@ download-kubectl() { ARCH=arm64 fi - KUBECTLURL="https://storage.googleapis.com/kubernetes-release/release/$WANTVERSION/bin/$UNAME/$ARCH/kubectl" + KUBECTLURL="https://dl.k8s.io/release//$WANTVERSION/bin/$UNAME/$ARCH/kubectl" HAVEVERSION="" if [ -x "${KUBECTLPATH}" ]; then diff --git a/test/integration/suites/key-manager-vault/00-setup-kind b/test/integration/suites/key-manager-vault/00-setup-kind new file mode 100755 index 0000000000..eac0016c0d --- /dev/null +++ b/test/integration/suites/key-manager-vault/00-setup-kind @@ -0,0 +1,33 @@ +#!/bin/bash + +# Create a temporary path that will be added to the PATH to avoid picking up +# binaries from the environment that aren't a version match. +mkdir -p ./bin + +KINDVERSION=v0.24.0 +KUBECTLVERSION=v1.31.2 +K8SIMAGE=kindest/node:v1.31.0 +HELMVERSION=v3.16.2 + +KIND_PATH=./bin/kind +KUBECTL_PATH=./bin/kubectl +HELM_PATH=./bin/helm + +# Download kind at the expected version at the given path. +download-kind "${KIND_PATH}" + +# Download kubectl at the expected version. +download-kubectl "${KUBECTL_PATH}" + +# Download helm at the expected version. +download-helm "${HELM_PATH}" + +# Start the kind cluster. +start-kind-cluster "${KIND_PATH}" vault-test + +# Load the given images in the cluster. +container_images=("spire-server:latest-local") +load-images "${KIND_PATH}" vault-test "${container_images[@]}" + +# Set the kubectl context. +set-kubectl-context "${KUBECTL_PATH}" kind-vault-test diff --git a/test/integration/suites/key-manager-vault/01-setup-vault b/test/integration/suites/key-manager-vault/01-setup-vault new file mode 100755 index 0000000000..fd295725c2 --- /dev/null +++ b/test/integration/suites/key-manager-vault/01-setup-vault @@ -0,0 +1,102 @@ +#!/bin/bash + +set -e -o pipefail + +source init-kubectl + +CHARTVERSION=0.28.1 + +log-info "installing hashicorp vault..." + +kubectl-exec-vault() { + ./bin/kubectl exec -n vault vault-0 -- $@ +} + +log-info "preparing certificates..." +# Prepare CSR for Vault instance +openssl ecparam -name prime256v1 -genkey -noout -out vault_key.pem +openssl req -new \ + -key vault_key.pem \ + -out vault_csr.pem \ + -subj "/C=US/O=system:nodes/CN=system:node:vault" \ + -reqexts v3 \ + -config <(cat /etc/ssl/openssl.cnf ; printf "\n[v3]\nsubjectAltName=@alt_names\n[alt_names]\nDNS.1=vault\nDNS.2=vault.vault.svc\nIP.1=127.0.0.1") +cat > csr.yaml </dev/null) ]]; do sleep 1; done' +./bin/kubectl get csr -n vault vault.svc -o jsonpath='{.status.certificate}' | openssl base64 -d -A -out vault.pem +./bin/kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[].cluster.certificate-authority-data}' \ + | base64 -d > vault_ca.pem +./bin/kubectl create secret generic vault-tls -n vault \ + --from-file=vault_key.pem=vault_key.pem \ + --from-file=vault.pem=vault.pem \ + --from-file=vault_ca.pem=vault_ca.pem + +./bin/helm repo add hashicorp https://helm.releases.hashicorp.com +./bin/helm install vault hashicorp/vault --namespace vault --version $CHARTVERSION -f conf/helm-values.yaml +./bin/kubectl wait -n kube-system --for=condition=available deployment --all --timeout=90s +./bin/kubectl wait pods -n vault --for=jsonpath='{.status.phase}'=Running vault-0 --timeout=90s + +# Initialize and unseal +log-info "initializing hashicorp vault..." +kubectl-exec-vault vault operator init -key-shares=1 -key-threshold=1 -format=json > cluster-keys.json +VAULT_UNSEAL_KEY=$(cat cluster-keys.json | jq -r ".unseal_keys_b64[]") +kubectl-exec-vault vault operator unseal $VAULT_UNSEAL_KEY +./bin/kubectl wait pods -n vault --for=condition=Ready vault-0 --timeout=60s +VAULT_ROOT_TOKEN=$(cat cluster-keys.json | jq -r ".root_token") +kubectl-exec-vault vault login $VAULT_ROOT_TOKEN > /dev/null + +./bin/kubectl cp -n vault cert_auth_ca.pem vault-0:/tmp/. +./bin/kubectl cp -n vault conf/configure-transit-secret-engine.sh vault-0:/tmp/. +./bin/kubectl cp -n vault conf/spire.hcl vault-0:tmp/. +./bin/kubectl cp -n vault conf/configure-auth-method.sh vault-0:/tmp/. + +# Configure Vault +log-info "configuring transit secret engine..." +kubectl-exec-vault /tmp/configure-transit-secret-engine.sh +log-info "configuring auth methods..." +kubectl-exec-vault /tmp/configure-auth-method.sh diff --git a/test/integration/suites/key-manager-vault/02-deploy-spire-and-verify-auth b/test/integration/suites/key-manager-vault/02-deploy-spire-and-verify-auth new file mode 100755 index 0000000000..a760bff610 --- /dev/null +++ b/test/integration/suites/key-manager-vault/02-deploy-spire-and-verify-auth @@ -0,0 +1,46 @@ +#!/bin/bash + +set -e -o pipefail + +./bin/kubectl create namespace spire +./bin/kubectl create secret -n spire generic vault-tls \ + --from-file=vault_ca.pem=vault_ca.pem + +# Verify AppRole Auth +log-info "verifying approle auth..." +APPROLE_ID=$(./bin/kubectl exec -n vault vault-0 -- vault read --format json auth/approle/role/spire/role-id | jq -r .data.role_id) +SECRET_ID=$(./bin/kubectl exec -n vault vault-0 -- vault write --format json -f auth/approle/role/spire/secret-id | jq -r .data.secret_id) +./bin/kubectl create secret -n spire generic vault-credential \ + --from-literal=approle_id=$APPROLE_ID \ + --from-literal=secret_id=$SECRET_ID +./bin/kubectl apply -k ./conf/server/approle-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for condition=Ready --timeout=60s +./bin/kubectl delete -k ./conf/server/approle-auth +./bin/kubectl delete secret -n spire vault-credential +./bin/kubectl wait pods -n spire -l app=spire-server --for=delete --timeout=60s + +# Verify K8s Auth +log-info "verifying k8s auth..." +./bin/kubectl apply -k ./conf/server/k8s-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for condition=Ready --timeout=60s +./bin/kubectl delete -k ./conf/server/k8s-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for=delete --timeout=60s + +# Verify Cert Auth +log-info "verifying cert auth..." +./bin/kubectl create secret -n spire generic vault-credential \ + --from-file=client.pem=client.pem \ + --from-file=client_key.pem=client_key.pem +./bin/kubectl apply -k ./conf/server/cert-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for condition=Ready --timeout=60s +./bin/kubectl delete -k ./conf/server/cert-auth +./bin/kubectl delete secret -n spire vault-credential +./bin/kubectl wait pods -n spire -l app=spire-server --for=delete --timeout=60s + +# Verify Token Auth +log-info "verifying token auth..." +TOKEN=$(./bin/kubectl exec -n vault vault-0 -- vault token create -policy=spire -ttl=1m -field=token) +./bin/kubectl create secret -n spire generic vault-credential \ + --from-literal=token=$TOKEN +./bin/kubectl apply -k ./conf/server/token-auth +./bin/kubectl wait pods -n spire -l app=spire-server --for condition=Ready --timeout=60s diff --git a/test/integration/suites/key-manager-vault/04-verify-token-renewal.sh b/test/integration/suites/key-manager-vault/04-verify-token-renewal.sh new file mode 100755 index 0000000000..a467e4bd50 --- /dev/null +++ b/test/integration/suites/key-manager-vault/04-verify-token-renewal.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -eo pipefail + +log-debug "verifying token renewal..." + +timeout=$(date -ud "1 minute 30 second" +%s) +count=0 + +while [ $(date -u +%s) -lt $timeout ]; do + count=`./bin/kubectl logs -n spire $(./bin/kubectl get pod -n spire -o name) | echo "$(grep "Successfully renew auth token" || [[ $? == 1 ]])" | wc -l` + if [ $count -ge 2 ]; then + log-info "token renewal is verified" + exit 0 + fi + sleep 10 +done + +fail-now "expected number of token renewal log not found" diff --git a/test/integration/suites/key-manager-vault/README.md b/test/integration/suites/key-manager-vault/README.md new file mode 100644 index 0000000000..5f4809397f --- /dev/null +++ b/test/integration/suites/key-manager-vault/README.md @@ -0,0 +1,9 @@ +# KeyManager HashiCorp Vault plugin suite + +## Description + +This suite sets up a Kubernetes cluster using [Kind](https://kind.sigs.k8s.io), +installs HashiCorp Vault. It then asserts the following: + +* SPIRE server successfully requests a key from the referenced Vault Transit Secret Engine +* Verifies that Auth Methods are configured successfully diff --git a/test/integration/suites/key-manager-vault/conf/configure-auth-method.sh b/test/integration/suites/key-manager-vault/conf/configure-auth-method.sh new file mode 100755 index 0000000000..82bb9bc22f --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/configure-auth-method.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +set -e -o pipefail + +# Create Policy +vault policy write spire /tmp/spire.hcl + +# Configure Vault Auth Method +vault auth enable approle +vault write auth/approle/role/spire \ + secret_id_ttl=120m \ + token_ttl=1m \ + policies="spire" + +# Configure K8s Auth Method +vault auth enable kubernetes +vault write auth/kubernetes/config kubernetes_host=https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT_HTTPS +vault write auth/kubernetes/role/my-role \ + bound_service_account_names=spire-server \ + bound_service_account_namespaces=spire \ + token_ttl=1m \ + policies=spire + +# Configure Cert Auth Method +vault auth enable cert +vault write auth/cert/certs/my-role \ + display_name=spire \ + token_ttl=1m \ + policies=spire \ + certificate=@/tmp/cert_auth_ca.pem diff --git a/test/integration/suites/key-manager-vault/conf/configure-transit-secret-engine.sh b/test/integration/suites/key-manager-vault/conf/configure-transit-secret-engine.sh new file mode 100755 index 0000000000..ddcd987a13 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/configure-transit-secret-engine.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e -o pipefail + +# Configure Root CA +vault secrets enable transit +vault secrets tune -max-lease-ttl=8760h transit diff --git a/test/integration/suites/key-manager-vault/conf/helm-values.yaml b/test/integration/suites/key-manager-vault/conf/helm-values.yaml new file mode 100644 index 0000000000..6607a9cbad --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/helm-values.yaml @@ -0,0 +1,33 @@ +global: + enabled: true + tlsDisable: false +server: + extraEnvironmentVars: + VAULT_CACERT: /vault/userconfig/vault-tls/vault_ca.pem + VAULT_TLSCERT: /vault/userconfig/vault-tls/vault.pem + VAULT_TLSKEY: /vault/userconfig/vault-tls/vault_key.pem + volumes: + - name: userconfig-vault-tls + secret: + defaultMode: 420 + secretName: vault-tls + volumeMounts: + - mountPath: /vault/userconfig/vault-tls + name: userconfig-vault-tls + readOnly: true + standalone: + enabled: "-" + config: | + listener "tcp" { + address = "[::]:8200" + cluster_address = "[::]:8201" + + tls_cert_file = "/vault/userconfig/vault-tls/vault.pem" + tls_key_file = "/vault/userconfig/vault-tls/vault_key.pem" + + tls_disable_client_certs = false + } + + storage "file" { + path = "/vault/data" + } diff --git a/test/integration/suites/key-manager-vault/conf/server/approle-auth/kustomization.yaml b/test/integration/suites/key-manager-vault/conf/server/approle-auth/kustomization.yaml new file mode 100644 index 0000000000..fca71c7ae2 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/approle-auth/kustomization.yaml @@ -0,0 +1,8 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base +patchesStrategicMerge: + - spire-server.yaml diff --git a/test/integration/suites/key-manager-vault/conf/server/approle-auth/spire-server.yaml b/test/integration/suites/key-manager-vault/conf/server/approle-auth/spire-server.yaml new file mode 100644 index 0000000000..af2ea53eed --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/approle-auth/spire-server.yaml @@ -0,0 +1,93 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + } + + plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/data/datastore.sqlite3" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + approle_auth {} + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + template: + spec: + containers: + - name: spire-server + env: + - name: VAULT_APPROLE_ID + valueFrom: + secretKeyRef: + name: vault-credential + key: approle_id + - name: VAULT_APPROLE_SECRET_ID + valueFrom: + secretKeyRef: + name: vault-credential + key: secret_id diff --git a/test/integration/suites/key-manager-vault/conf/server/base/kustomization.yaml b/test/integration/suites/key-manager-vault/conf/server/base/kustomization.yaml new file mode 100644 index 0000000000..c87a9a25d0 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/base/kustomization.yaml @@ -0,0 +1,10 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# list of Resource Config to be Applied +resources: + - spire-server.yaml + +# namespace to deploy all Resources to +namespace: spire diff --git a/test/integration/suites/key-manager-vault/conf/server/base/spire-server.yaml b/test/integration/suites/key-manager-vault/conf/server/base/spire-server.yaml new file mode 100644 index 0000000000..05b8bfe53c --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/base/spire-server.yaml @@ -0,0 +1,243 @@ +# ServiceAccount used by the SPIRE server. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: spire-server + namespace: spire + +--- + +# Required cluster role to allow spire-server to query k8s API server +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role +rules: + - apiGroups: [""] + resources: ["pods", "nodes"] + verbs: ["get", "list", "watch"] + +--- + +# Binds above cluster role to spire-server service account +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-cluster-role-binding + namespace: spire +subjects: + - kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: ClusterRole + name: spire-server-cluster-role + apiGroup: rbac.authorization.k8s.io + +--- + +# Role for the SPIRE server +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + namespace: spire + name: spire-server-role +rules: + # allow "get" access to pods (to resolve selectors for PSAT attestation) + - apiGroups: [""] + resources: ["pods"] + verbs: ["get"] + # allow access to "get" and "patch" the spire-bundle ConfigMap (for SPIRE + # agent bootstrapping, see the spire-bundle ConfigMap below) + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["spire-bundle"] + verbs: ["get", "patch"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["create"] + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["create", "update", "get"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create"] + +--- + +# RoleBinding granting the spire-server-role to the SPIRE server +# service account. +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: spire-server-role-binding + namespace: spire +subjects: + - kind: ServiceAccount + name: spire-server + namespace: spire +roleRef: + kind: Role + name: spire-server-role + apiGroup: rbac.authorization.k8s.io + +--- + +# ConfigMap containing the latest trust bundle for the trust domain. It is +# updated by SPIRE using the k8sbundle notifier plugin. SPIRE agents mount +# this config map and use the certificate to bootstrap trust with the SPIRE +# server during attestation. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-bundle + namespace: spire + +--- + +# ConfigMap containing the SPIRE server configuration. +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + } + + plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/data/datastore.sqlite3" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "disk" { + plugin_data { + keys_path = "/run/spire/data/keys.json" + } + } + + UpstreamAuthority "vault" { + plugin_data { + vault_addr="http://vault.vault.svc:8200/" + token_auth {} + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + replicas: 1 + selector: + matchLabels: + app: spire-server + template: + metadata: + namespace: spire + labels: + app: spire-server + spec: + serviceAccountName: spire-server + shareProcessNamespace: true + containers: + - name: spire-server + image: spire-server:latest-local + imagePullPolicy: Never + args: ["-config", "/run/spire/config/server.conf"] + ports: + - containerPort: 8081 + volumeMounts: + - name: spire-config + mountPath: /run/spire/config + readOnly: true + - name: vault-tls + mountPath: "/run/spire/vault" + readOnly: true + livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + volumes: + - name: spire-config + configMap: + name: spire-server + - name: vault-tls + secret: + secretName: vault-tls + +--- + +# Service definition for SPIRE server defining the gRPC port. +apiVersion: v1 +kind: Service +metadata: + name: spire-server + namespace: spire +spec: + type: NodePort + ports: + - name: grpc + port: 8081 + targetPort: 8081 + protocol: TCP + selector: + app: spire-server diff --git a/test/integration/suites/key-manager-vault/conf/server/cert-auth/kustomization.yaml b/test/integration/suites/key-manager-vault/conf/server/cert-auth/kustomization.yaml new file mode 100644 index 0000000000..fca71c7ae2 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/cert-auth/kustomization.yaml @@ -0,0 +1,8 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base +patchesStrategicMerge: + - spire-server.yaml diff --git a/test/integration/suites/key-manager-vault/conf/server/cert-auth/spire-server.yaml b/test/integration/suites/key-manager-vault/conf/server/cert-auth/spire-server.yaml new file mode 100644 index 0000000000..975ca24e44 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/cert-auth/spire-server.yaml @@ -0,0 +1,93 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + } + + plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/data/datastore.sqlite3" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + cert_auth { + client_cert_path="/run/spire/vault-auth/client.pem" + client_key_path="/run/spire/vault-auth/client_key.pem" + } + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + template: + spec: + volumes: + - name: vault-credential + secret: + secretName: vault-credential + containers: + - name: spire-server + volumeMounts: + - name: vault-credential + mountPath: "/run/spire/vault-auth" + readOnly: true diff --git a/test/integration/suites/key-manager-vault/conf/server/k8s-auth/kustomization.yaml b/test/integration/suites/key-manager-vault/conf/server/k8s-auth/kustomization.yaml new file mode 100644 index 0000000000..fca71c7ae2 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/k8s-auth/kustomization.yaml @@ -0,0 +1,8 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base +patchesStrategicMerge: + - spire-server.yaml diff --git a/test/integration/suites/key-manager-vault/conf/server/k8s-auth/spire-server.yaml b/test/integration/suites/key-manager-vault/conf/server/k8s-auth/spire-server.yaml new file mode 100644 index 0000000000..a964962596 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/k8s-auth/spire-server.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + } + + plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/data/datastore.sqlite3" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + k8s_auth { + k8s_auth_role_name = "my-role" + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } diff --git a/test/integration/suites/key-manager-vault/conf/server/token-auth/kustomization.yaml b/test/integration/suites/key-manager-vault/conf/server/token-auth/kustomization.yaml new file mode 100644 index 0000000000..fca71c7ae2 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/token-auth/kustomization.yaml @@ -0,0 +1,8 @@ +# kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base +patchesStrategicMerge: + - spire-server.yaml diff --git a/test/integration/suites/key-manager-vault/conf/server/token-auth/spire-server.yaml b/test/integration/suites/key-manager-vault/conf/server/token-auth/spire-server.yaml new file mode 100644 index 0000000000..3613ed7841 --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/server/token-auth/spire-server.yaml @@ -0,0 +1,88 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: spire-server + namespace: spire +data: + server.conf: | + server { + bind_address = "0.0.0.0" + bind_port = "8081" + trust_domain = "example.org" + data_dir = "/run/spire/data" + log_level = "DEBUG" + default_x509_svid_ttl = "1h" + ca_ttl = "12h" + ca_subject { + country = ["US"] + organization = ["SPIFFE"] + common_name = "" + } + } + + plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/run/spire/data/datastore.sqlite3" + } + } + + NodeAttestor "k8s_psat" { + plugin_data { + clusters = { + "example-cluster" = { + service_account_allow_list = ["spire:spire-agent"] + } + } + } + } + + KeyManager "hashicorp_vault" { + plugin_data { + vault_addr="https://vault.vault.svc:8200/" + ca_cert_path="/run/spire/vault/vault_ca.pem" + pki_mount_point="pki_int" + token_auth {} + } + } + + Notifier "k8sbundle" { + plugin_data { + # This plugin updates the bundle.crt value in the spire:spire-bundle + # ConfigMap by default, so no additional configuration is necessary. + } + } + } + + health_checks { + listener_enabled = true + bind_address = "0.0.0.0" + bind_port = "8080" + live_path = "/live" + ready_path = "/ready" + } + +--- + +# This is the Deployment for the SPIRE server. It waits for SPIRE database to +# initialize and uses the SPIRE healthcheck command for liveness/readiness +# probes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spire-server + namespace: spire + labels: + app: spire-server +spec: + template: + spec: + containers: + - name: spire-server + env: + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-credential + key: token diff --git a/test/integration/suites/key-manager-vault/conf/spire.hcl b/test/integration/suites/key-manager-vault/conf/spire.hcl new file mode 100644 index 0000000000..0f3bb2116a --- /dev/null +++ b/test/integration/suites/key-manager-vault/conf/spire.hcl @@ -0,0 +1,3 @@ +path "transit/*" { + capabilities = [ "create", "read", "update", "delete", "list" ] +} diff --git a/test/integration/suites/key-manager-vault/init-kubectl b/test/integration/suites/key-manager-vault/init-kubectl new file mode 100644 index 0000000000..a167777059 --- /dev/null +++ b/test/integration/suites/key-manager-vault/init-kubectl @@ -0,0 +1,8 @@ +#!/bin/bash + +KUBECONFIG="${RUNDIR}/kubeconfig" +if [ ! -f "${RUNDIR}/kubeconfig" ]; then + ./bin/kind get kubeconfig --name=vault-test > "${RUNDIR}/kubeconfig" +fi +export KUBECONFIG + diff --git a/test/integration/suites/key-manager-vault/teardown b/test/integration/suites/key-manager-vault/teardown new file mode 100755 index 0000000000..83c7ae74e5 --- /dev/null +++ b/test/integration/suites/key-manager-vault/teardown @@ -0,0 +1,10 @@ +#!/bin/bash + +source init-kubectl + +if [ -z "$SUCCESS" ]; then + ./bin/kubectl -nspire logs deployment/spire-server --all-containers || true +fi + +export KUBECONFIG= +./bin/kind delete cluster --name vault-test