diff --git a/internal/keystore/credhub/credhub.go b/internal/keystore/credhub/credhub.go new file mode 100644 index 00000000..5e96e837 --- /dev/null +++ b/internal/keystore/credhub/credhub.go @@ -0,0 +1,334 @@ +// Copyright 2023 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package credhub + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/golang/groupcache/singleflight" + "github.com/google/uuid" + "github.com/minio/kes" + "github.com/minio/kes/internal/keystore" + kesdk "github.com/minio/kms-go/kes" +) + +const ( + contentType = "Content-Type" + applicationJSON = "application/json" +) + +// Config holds the configuration settings for connecting to a CredHub service. +type Config struct { + BaseURL string // The base URL endpoint of the CredHub service. + EnableMutualTLS bool // If set to true, enables mutual TLS. + ClientCertFilePath string // Path to the client's certificate file used for mutual TLS authentication. + ClientKeyFilePath string // Path to the client's private key file used for mutual TLS authentication. + ServerInsecureSkipVerify bool // If set to true, server's certificate will not be verified against the provided CA certificate. + ServerCaCertFilePath string // Path to the CA certificate file for verifying the CredHub server's certificate. + Namespace string // A namespace within CredHub where credentials are stored. + ForceBase64ValuesEncoding bool // If set to true, forces encoding of all the values as base64 before storage. +} + +// Certs contains the certificates needed for mutual TLS authentication. +type Certs struct { + ServerCaCert *x509.Certificate + ClientKeyPair tls.Certificate +} + +// Validate checks the configuration for correctness and loads the necessary certificates for mutual TLS authentication. +// It returns a Certs object containing the server CA certificate and client key pair, or an error if validation fails. +func (c *Config) Validate() (*Certs, error) { + certs := &Certs{} + if c.BaseURL == "" { + return certs, errors.New("credhub config: `BaseURL` can't be empty") + } + if c.Namespace == "" { + return certs, errors.New("credhub config: `Namespace` can't be empty") + } + if !c.ServerInsecureSkipVerify { + if c.ServerCaCertFilePath == "" { + return certs, errors.New("credhub config: `ServerCaCertFilePath` can't be empty when `ServerInsecureSkipVerify` is false") + } + _, sCertDerBytes, err := c.validatePemFile(c.ServerCaCertFilePath, "ServerCaCertFilePath") + if err != nil { + return nil, err + } + certs.ServerCaCert, err = x509.ParseCertificate(sCertDerBytes) + if err != nil { + return nil, fmt.Errorf("credhub config: error parsing the certificate '%s': %v", "ServerCaCertFilePath", err) + } + } + if c.EnableMutualTLS { + if c.ClientCertFilePath == "" || c.ClientKeyFilePath == "" { + return certs, errors.New("credhub config: `ClientCertFilePath` and `ClientKeyFilePath` can't be empty when `EnableMutualTLS` is true") + } + cCertPemBytes, cCertDerBytes, err := c.validatePemFile(c.ClientCertFilePath, "ClientCertFilePath") + if err != nil { + return certs, err + } + _, err = x509.ParseCertificate(cCertDerBytes) + if err != nil { + return nil, fmt.Errorf("credhub config: error parsing the certificate '%s': %v", "ClientCertFilePath", err) + } + cKeyPemBytes, _, err := c.validatePemFile(c.ClientKeyFilePath, "ClientKeyFilePath") + if err != nil { + return certs, err + } + certs.ClientKeyPair, err = tls.X509KeyPair(cCertPemBytes, cKeyPemBytes) + if err != nil { + return certs, err + } + } + return certs, nil +} + +func (c *Config) validatePemFile(path, name string) (pemBytes, derBytes []byte, err error) { + pemBytes, err = os.ReadFile(path) + if err != nil { + return pemBytes, nil, fmt.Errorf("credhub config: failed to load PEM file '%s'='%s': %v", name, path, err) + } + derBlock, _ := pem.Decode(pemBytes) + if derBlock == nil { + return pemBytes, nil, fmt.Errorf("credhub config: failed to decode the '%s'='%s' from PEM format, no PEM data found", name, path) + } + return pemBytes, derBlock.Bytes, nil +} + +// Store represents a layer that interacts with a CredHub service using HTTP protocol. +type Store struct { + LastError error + config *Config + client httpClient + sfGroup singleflight.Group +} + +// NewStore creates a new instance of Store, initializing it with the provided configuration. +// It returns an error if the HTTP client initialization fails. +func NewStore(_ context.Context, config *Config) (*Store, error) { + client, err := newHTTPMTLSClient(config) + if err != nil { + return nil, err + } + return &Store{config: config, client: client}, nil +} + +// Status returns the current state of the KeyStore. +// +// CredHub "Get Server Status": +// - https://docs.cloudfoundry.org/api/credhub/version/main/#_get_server_status +// - `credhub curl -X=GET -p /health` +func (s *Store) Status(ctx context.Context) (kes.KeyStoreState, error) { + uri := "/health" + startTime := time.Now() + resp := s.client.doRequest(ctx, http.MethodGet, uri, nil) + defer resp.closeResource() + if resp.err != nil { + return kes.KeyStoreState{Latency: 0}, resp.err + } + state := kes.KeyStoreState{ + Latency: time.Since(startTime), + } + + if resp.isStatusCode2xx() { + var responseData struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.body).Decode(&responseData); err != nil { + return state, fmt.Errorf("failed to parse response: %v", err) + } + if responseData.Status == "UP" { + return state, nil + } + return state, fmt.Errorf("CredHub is not UP, status: %s", responseData.Status) + + } + return state, fmt.Errorf("the CredHub (%s) is not healthy, status: %s", uri, resp.status) +} + +// Create creates a new entry with the given name if and only +// if no such entry exists. +// Otherwise, Create returns kes.ErrKeyExists. +// +// CredHub: there is no method to do it, implemented workaround with limitations +func (s *Store) Create(ctx context.Context, name string, value []byte) error { + return s.create(ctx, name, value, uuid.New().String()) +} + +func (s *Store) create(ctx context.Context, name string, value []byte, operationID string) error { + _, err := s.sfGroup.Do(s.config.Namespace+"/"+name, func() (interface{}, error) { + _, err := s.Get(ctx, name) + switch { + case err == nil: + return nil, fmt.Errorf("key '%s' already exists: %w", name, kesdk.ErrKeyExists) + case errors.Is(err, kesdk.ErrKeyNotFound): + return nil, s.put(ctx, name, value, operationID) + default: + return nil, err + } + }) + return err +} + +// CredHub "Set a Value Credential": +// - https://docs.cloudfoundry.org/api/credhub/version/main/#_set_a_value_credential +// - `credhub curl -X=PUT -p "/api/v1/data" -d='{"name":"/test-namespace/key-1","type":"value","value":"1"}` +func (s *Store) put(ctx context.Context, name string, value []byte, operationID string) error { + uri := "/api/v1/data" + valueStr := bytesToJSONString(value, s.config.ForceBase64ValuesEncoding) + data := map[string]interface{}{ + "name": s.config.Namespace + "/" + name, + "type": "value", + "value": valueStr, + "metadata": map[string]string{ + "operation_id": operationID, + }, + } + payload, err := json.Marshal(data) + if err != nil { + return err + } + resp := s.client.doRequest(ctx, http.MethodPut, uri, bytes.NewBuffer(payload)) + defer resp.closeResource() + if resp.err != nil { + return resp.err + } + + if resp.isStatusCode2xx() { + var responseData struct { + Value string `json:"value"` + Metadata struct { + OperationID string `json:"operation_id"` + } `json:"metadata"` + } + if err := json.NewDecoder(resp.body).Decode(&responseData); err != nil { + return fmt.Errorf("can't decode response of put entry (status: %s)", resp.status) + } + if responseData.Value != valueStr { + return fmt.Errorf("key '%s' was inserted but overwritten by other process (the returned value is different from the the one sent): %w", name, kesdk.ErrKeyExists) + } + if responseData.Metadata.OperationID != operationID { + return fmt.Errorf("key '%s' was inserted but overwritten by other process (operation ID %s != %s): %w", name, responseData.Metadata.OperationID, operationID, kesdk.ErrKeyExists) + } + return nil + + } + return fmt.Errorf("failed to put entry (status: %s)", resp.status) +} + +// Delete removes the entry. It may return either no error or +// kes.ErrKeyNotFound if no such entry exists. +// +// CredHub "Delete a Credential": +// - https://docs.cloudfoundry.org/api/credhub/version/main/#_delete_a_credential +// - `credhub curl -X=DELETE -p "/api/v1/data?name=/test-namespace/key-2"` +func (s *Store) Delete(ctx context.Context, name string) error { + uri := fmt.Sprintf("/api/v1/data?name=%s/%s", s.config.Namespace, name) + resp := s.client.doRequest(ctx, http.MethodDelete, uri, nil) + defer resp.closeResource() + if resp.err != nil { + return resp.err + } + + if resp.statusCode == http.StatusNotFound { + return kesdk.ErrKeyNotFound + } else if !resp.isStatusCode2xx() { + return fmt.Errorf("failed to delete entry: %s", resp.status) + } + return nil +} + +// Get returns the value for the given name. It returns +// kes.ErrKeyNotFound if no such entry exits. +// +// CredHub "Get a Credential by Name": +// - https://docs.cloudfoundry.org/api/credhub/version/main/#_get_a_credential_by_name +// - `credhub curl -X=GET -p "/api/v1/data?name=/test-namespace/key-4¤t=true"` +func (s *Store) Get(ctx context.Context, name string) ([]byte, error) { + uri := fmt.Sprintf("/api/v1/data?current=true&name=%s/%s", s.config.Namespace, name) + resp := s.client.doRequest(ctx, http.MethodGet, uri, nil) + defer resp.closeResource() + if resp.err != nil { + return nil, resp.err + } + + if resp.statusCode == http.StatusNotFound { + return nil, kesdk.ErrKeyNotFound + } else if !resp.isStatusCode2xx() { + return nil, fmt.Errorf("failed to get entry (status: %s)", resp.status) + } + var responseData struct { + Data []struct { + Value string `json:"value"` + } `json:"data"` + } + if err := json.NewDecoder(resp.body).Decode(&responseData); err != nil { + return nil, err + } + + if len(responseData.Data) == 0 { + return nil, kesdk.ErrKeyNotFound + } + if len(responseData.Data) > 1 { + return nil, fmt.Errorf("received multiple entries (%d) for the same key", len(responseData.Data)) + } + return jsonStringToBytes(responseData.Data[0].Value) +} + +// List returns the first n key names, that start with the given +// prefix, and the next prefix from which the listing should +// continue. +// +// It returns all keys with the prefix if n < 0 and less than n +// names if n is greater than the number of keys with the prefix. +// +// An empty prefix matches any key name. At the end of the listing +// or when there are no (more) keys starting with the prefix, the +// returned prefix is empty. +// +// CredHub "Find a Credential by Name-Like": +// - https://docs.cloudfoundry.org/api/credhub/version/main/#_find_a_credential_by_name_like +// - `credhub curl -X=GET -p "/api/v1/data?path=/test-namespace/"` +func (s *Store) List(ctx context.Context, prefix string, n int) ([]string, string, error) { + pathPrefix := s.config.Namespace + "/" + uri := fmt.Sprintf("/api/v1/data?name-like=%s%s", pathPrefix, prefix) + resp := s.client.doRequest(ctx, http.MethodGet, uri, nil) + defer resp.closeResource() + if resp.err != nil { + return nil, "", resp.err + } + + if !resp.isStatusCode2xx() { + return nil, "", fmt.Errorf("failed to list entries (status: %s)", resp.status) + } + var responseData struct { + Credentials []struct { + Name string `json:"name"` + } `json:"credentials"` + } + if err := json.NewDecoder(resp.body).Decode(&responseData); err != nil { + return nil, "", err + } + + var names []string + for _, credential := range responseData.Credentials { + names = append(names, strings.TrimPrefix(credential.Name, pathPrefix)) + } + resNames, resPrefix, err := keystore.List(names, prefix, n) + return resNames, resPrefix, err +} + +// Close terminate or release resources that were opened or acquired. +func (s *Store) Close() error { return nil } diff --git a/internal/keystore/credhub/credhub_test.go b/internal/keystore/credhub/credhub_test.go new file mode 100644 index 00000000..8ef0f55f --- /dev/null +++ b/internal/keystore/credhub/credhub_test.go @@ -0,0 +1,413 @@ +package credhub + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "testing" + + "github.com/minio/kes/internal/api" + "github.com/minio/kms-go/kes" +) + +/** CredHub Rest API contract tests. +The following is checked: +- correctness of requests: method, url, body +- correctness of responses: status, body parsing +*/ + +const testNamespace = "/test-namespace" + +// `curl -v --cert ./client.cert --key ./client.key --cacert ./server-ca.cert https://localhost:8844/api/v1/data?path=/` +func TestStore_MTLS(t *testing.T) { + t.Run("get status request contract", func(t *testing.T) { + t.Skip("skipping due to this being an integration test that requires specific configuration for a CredHub instance") + client, err := newHTTPMTLSClient(&Config{ + BaseURL: "https://localhost:8844", + Namespace: testNamespace, + EnableMutualTLS: true, + ClientCertFilePath: "../../../client.cert", + ClientKeyFilePath: "../../../client.key", + ServerInsecureSkipVerify: false, + ServerCaCertFilePath: "../../../server-ca.cert", + }) + assertNoError(t, err) + resp := client.doRequest(context.Background(), "GET", "/api/v1/data?path=/", nil) + assertNoError(t, resp.err) + fmt.Println(resp.status) + }) +} + +// `credhub curl -X=GET -p /health` +func TestStore_Status(t *testing.T) { + fakeClient, store := NewFakeStore() + + t.Run("get status request contract", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = `{"status" : "UP"}` + _, err := store.Status(context.Background()) + assertNoError(t, err) + assertRequest(t, fakeClient, "GET", "/health") + }) + + t.Run("returns error for status 200 and not 'UP'", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = `{"status" : "DOWN"}` + _, err := store.Status(context.Background()) + assertError(t, err) + }) + + t.Run("returns error for non-200 status", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 500 + _, err := store.Status(context.Background()) + assertError(t, err) + }) +} + +// `credhub curl -X=PUT -p "/api/v1/data" -d='{"name":"/test-namespace/key-1","type":"value","value":"1"}` +func TestStore_put(t *testing.T) { + fakeClient, store := NewFakeStore() + + t.Run("PUT string value without encoding request contract", func(t *testing.T) { + fakeClient.respStatusCodes["PUT"] = 200 + const key = "key" + const value = "string-value" + const operationID = "test" + fakeClient.respBody = fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, value, operationID) + store.config.ForceBase64ValuesEncoding = false + err := store.put(context.Background(), key, []byte(value), operationID) + assertNoError(t, err) + assertRequestWithJSONBody(t, fakeClient, "PUT", "/api/v1/data", + fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, value, operationID)) + }) + + t.Run("PUT string value with forced Base64 encoding request contract", func(t *testing.T) { + fakeClient.respStatusCodes["PUT"] = 200 + const key = "key" + const value = "string-value" + const encodedValue = "Base64:c3RyaW5nLXZhbHVl" + const operationID = "test" + store.config.ForceBase64ValuesEncoding = true + fakeClient.respBody = fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, encodedValue, operationID) + err := store.put(context.Background(), key, []byte(value), operationID) + assertNoError(t, err) + assertRequestWithJSONBody(t, fakeClient, "PUT", "/api/v1/data", + fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, encodedValue, operationID)) + }) + t.Run("PUT bytes value with not valid UTF-8 bytes", func(t *testing.T) { + fakeClient.respStatusCodes["PUT"] = 200 + const key = "key" + value := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 80, 114, 122, 255, 121, 107, 108, 255} + const encodedValue = "Base64:AAECAwQFBgcICQpQcnr/eWts/w==" + const operationID = "test" + store.config.ForceBase64ValuesEncoding = false + fakeClient.respBody = fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, encodedValue, operationID) + err := store.put(context.Background(), key, value, operationID) + assertNoError(t, err) + assertRequestWithJSONBody(t, fakeClient, "PUT", "/api/v1/data", + fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, encodedValue, operationID)) + }) + t.Run("PUT string value starts with 'Base64:' request contract", func(t *testing.T) { + fakeClient.respStatusCodes["PUT"] = 200 + const key = "key" + const value = "Base64:string-value" + const encodedValue = "Base64:QmFzZTY0OnN0cmluZy12YWx1ZQ==" + const operationID = "test" + store.config.ForceBase64ValuesEncoding = false + fakeClient.respBody = fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, encodedValue, operationID) + err := store.put(context.Background(), key, []byte(value), operationID) + assertNoError(t, err) + assertRequestWithJSONBody(t, fakeClient, "PUT", "/api/v1/data", + fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, encodedValue, operationID)) + }) +} + +func TestStore_Create(t *testing.T) { + fakeClient, store := NewFakeStore() + existsRespBody := `{"data":[{"value":"something"}]}` + + t.Run("create element that exists", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = existsRespBody + const key = "key" + const value = "string-value" + err := store.Create(context.Background(), key, []byte(value)) + assertErrorIs(t, err, kes.ErrKeyExists) + assertAPIErrorStatus(t, err, http.StatusBadRequest) + }) + t.Run("create element that doesn't exist", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 404 + fakeClient.respStatusCodes["PUT"] = 200 + const key = "key" + const value = "string-value" + const operationID = "test" + fakeClient.respBody = fmt.Sprintf(`{"name":"%s/%s","type":"value","value":"%s","metadata":{"operation_id":"%s"}}`, testNamespace, key, value, operationID) + err := store.create(context.Background(), key, []byte(value), operationID) + assertNoError(t, err) + }) + t.Run("create element unknown error", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = existsRespBody + fakeClient.respStatusCodes["PUT"] = 500 + const key = "key" + const value = "string-value" + err := store.Create(context.Background(), key, []byte(value)) + assertError(t, err) + }) +} + +// `credhub curl -X=GET -p "/api/v1/data?name=/test-namespace/key-4¤t=true"` +func TestStore_Get(t *testing.T) { + fakeClient, store := NewFakeStore() + + t.Run("GET string value without encoding request contract", func(t *testing.T) { + const key = "key" + const value = "string-value" + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = fmt.Sprintf(` + { + "data" : [ { + "type" : "value", + "version_created_at" : "2019-02-01T20:37:52Z", + "id" : "2e094eda-719c-43cb-a0f5-04face0a79be", + "name" : "%s/%s", + "metadata" : { + "description" : "example metadata" + }, + "value" : "%s" + } ] + } + `, testNamespace, key, value) + b, err := store.Get(context.Background(), key) + assertNoError(t, err) + assertRequest(t, fakeClient, "GET", fmt.Sprintf("/api/v1/data?current=true&name=%s/%s", testNamespace, key)) + assertEqualComparable(t, value, string(b)) + }) + t.Run("GET bytes value with Base64 encoding request contract", func(t *testing.T) { + const key = "key" + value := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 80, 114, 122, 255, 121, 107, 108, 255} + encodedValue := bytesToJSONString(value, true) + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = fmt.Sprintf(` + { + "data" : [ { + "type" : "value", + "version_created_at" : "2019-02-01T20:37:52Z", + "id" : "2e094eda-719c-43cb-a0f5-04face0a79be", + "name" : "%s/%s", + "metadata" : { + "description" : "example metadata" + }, + "value" : "%s" + } ] + } + `, testNamespace, key, encodedValue) + b, err := store.Get(context.Background(), key) + assertNoError(t, err) + assertRequest(t, fakeClient, "GET", fmt.Sprintf("/api/v1/data?current=true&name=%s/%s", testNamespace, key)) + assertEqualBytes(t, value, b) + }) + + t.Run("GET element that doesn't exist", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 404 + const name = "element-name" + _, err := store.Get(context.Background(), name) + assertErrorIs(t, err, kes.ErrKeyNotFound) + assertAPIErrorStatus(t, err, http.StatusNotFound) + }) +} + +// `credhub curl -X=DELETE -p "/api/v1/data?name=/test-namespace/element-name"` +func TestStore_Delete(t *testing.T) { + fakeClient, store := NewFakeStore() + + t.Run("DELETE element request contract", func(t *testing.T) { + fakeClient.respStatusCodes["DELETE"] = 200 + const name = "element-name" + err := store.Delete(context.Background(), name) + assertNoError(t, err) + assertRequest(t, fakeClient, "DELETE", fmt.Sprintf("/api/v1/data?name=%s/%s", testNamespace, name)) + }) + + t.Run("DELETE element that doesn't exist", func(t *testing.T) { + fakeClient.respStatusCodes["DELETE"] = 404 + const name = "element-name" + err := store.Delete(context.Background(), name) + assertErrorIs(t, err, kes.ErrKeyNotFound) + assertAPIErrorStatus(t, err, http.StatusNotFound) + }) +} + +// `credhub curl -X=GET -p "/api/v1/data?name-like=/test-namespace/prefix"` +func TestStore_List(t *testing.T) { + fakeClient, store := NewFakeStore() + + t.Run("list keys request contract", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = `{"credentials":[]}` + _, _, err := store.List(context.Background(), "", 1) + assertNoError(t, err) + assertRequest(t, fakeClient, "GET", fmt.Sprintf("/api/v1/data?name-like=%s", testNamespace+"/")) + }) + + t.Run("returns empty list", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = `{"credentials":[]}` + list, prefix, err := store.List(context.Background(), "prefix", 1) + assertNoError(t, err) + assertEqualComparable(t, 0, len(list)) + assertEqualComparable(t, "", prefix) + }) + + t.Run("returns list of two elements", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = `{"credentials":[ + {"name":"/test-namespace/prefix-key-2"}, + {"name":"/test-namespace/prefix-key-1"}, + {"name":"/test-namespace/other-key"} + ]}` + list, prefix, err := store.List(context.Background(), "prefix", 2) + assertNoError(t, err) + assertEqualComparable(t, 2, len(list)) + assertEqualComparable(t, "prefix-key-1", list[0]) + assertEqualComparable(t, "prefix-key-2", list[1]) + assertEqualComparable(t, "", prefix) + }) + + t.Run("returns limited list with continuation", func(t *testing.T) { + fakeClient.respStatusCodes["GET"] = 200 + fakeClient.respBody = `{"credentials":[ + {"name":"/test-namespace/prefix-key-3"}, + {"name":"/test-namespace/prefix-key-1"}, + {"name":"/test-namespace/other-key"}, + {"name":"/test-namespace/prefix-key-2"} + ]}` + list, prefix, err := store.List(context.Background(), "prefix", 2) + assertNoError(t, err) + assertEqualComparable(t, 2, len(list)) + assertEqualComparable(t, "prefix-key-1", list[0]) + assertEqualComparable(t, "prefix-key-2", list[1]) + assertEqualComparable(t, "prefix-key-3", prefix) + }) +} + +// === tools: + +func NewFakeStore() (*FakeHTTPClient, *Store) { + fakeClient := &FakeHTTPClient{respStatusCodes: map[string]int{}} + store := &Store{ + config: &Config{Namespace: testNamespace}, + client: fakeClient, + } + return fakeClient, store +} + +type FakeHTTPClient struct { + reqMethod string + reqURI string + reqBody string + respStatusCodes map[string]int + respStatus string + respBody string + error error +} + +type FakeReadCloser struct { + io.Reader +} + +func (m *FakeReadCloser) Close() error { + return nil +} + +func (c *FakeHTTPClient) doRequest(_ context.Context, method, url string, body io.Reader) httpResponse { + c.reqMethod = method + c.reqURI = url + c.reqBody = "" + if body != nil { + bodyBytes, err := io.ReadAll(body) + if err == nil { + c.reqBody = string(bodyBytes) + } + } + mockBody := &FakeReadCloser{ + Reader: bytes.NewBufferString(c.respBody), + } + return httpResponse{statusCode: c.respStatusCodes[method], status: c.respStatus, body: mockBody, err: c.error} +} + +func assertError(t *testing.T, err error) { + if err == nil { + t.Fatal("expected an error, got nil") + } +} + +func assertErrorIs(t *testing.T, err, target error) { + if err == nil || target == nil { + t.Fatal("error can't be null") + } + if !errors.Is(err, target) { + t.Fatal(fmt.Sprintf("error '%v' isn't '%v'", err, target)) + } +} + +func assertAPIErrorStatus(t *testing.T, err error, status int) { + if err == nil { + t.Fatal("error can't be null") + } + apiErr, isIt := api.IsError(err) + if !isIt { + t.Fatal(fmt.Sprintf("error '%+v' isn't api error '%+v'", err, apiErr)) + } + if apiErr.Status() != status { + t.Fatal(fmt.Sprintf("expect error status '%d', got '%d'", status, apiErr.Status())) + } +} + +func assertNoError(t *testing.T, err error) { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func assertEqualComparable(t *testing.T, expected, got any) { + if expected != got { + t.Fatalf("expected '%v' got '%v'", expected, got) + } +} + +func assertEqualBytes(t *testing.T, expected, got []byte) { + if !bytes.Equal(expected, got) { + t.Fatalf("expected '%v' got '%v'", expected, got) + } +} + +func assertRequest(t *testing.T, fc *FakeHTTPClient, method, uri string) { + if fc.reqMethod != method { + t.Fatalf("expected requested method '%s' but got '%s'", method, fc.reqMethod) + } + if fc.reqURI != uri { + t.Fatalf("expected requested uri '%s' but got '%s'", uri, fc.reqURI) + } +} + +func assertRequestWithJSONBody(t *testing.T, fc *FakeHTTPClient, method, uri string, jsonBody string) { + assertRequest(t, fc, method, uri) + + var gotJSON, expectedJSON interface{} + err1 := json.Unmarshal([]byte(fc.reqBody), &gotJSON) + err2 := json.Unmarshal([]byte(jsonBody), &expectedJSON) + + if err1 != nil || err2 != nil { + t.Fatalf("jsons deserialization errors: %v, %v", err1, err2) + } + + if !reflect.DeepEqual(gotJSON, expectedJSON) { + t.Fatalf("expected requested body '%s' but got '%s'", jsonBody, fc.reqBody) + } +} diff --git a/internal/keystore/credhub/http_client.go b/internal/keystore/credhub/http_client.go new file mode 100644 index 00000000..18ba4dbd --- /dev/null +++ b/internal/keystore/credhub/http_client.go @@ -0,0 +1,76 @@ +package credhub + +import ( + "context" + "crypto/tls" + "crypto/x509" + "io" + "net/http" +) + +type httpResponse struct { + statusCode int + status string + body io.ReadCloser + err error +} + +func newHTTPResponseError(err error) httpResponse { + return httpResponse{statusCode: -1, status: "", body: nil, err: err} +} + +func (c *httpResponse) isStatusCode2xx() bool { + return c.statusCode >= http.StatusOK && c.statusCode < http.StatusMultipleChoices +} + +func (c *httpResponse) closeResource() { + if c.body != nil { + _ = c.body.Close() + } +} + +type httpClient interface { + doRequest(ctx context.Context, method, uri string, body io.Reader) httpResponse +} + +type httpMTLSClient struct { + baseURL string + httpClient *http.Client +} + +func newHTTPMTLSClient(config *Config) (httpClient, error) { + certs, err := config.Validate() + if err != nil { + return nil, err + } + tlsConfig := &tls.Config{ + InsecureSkipVerify: config.ServerInsecureSkipVerify, + } + if !config.ServerInsecureSkipVerify { + // Setup mutual TLS - server + caCertPool := x509.NewCertPool() + caCertPool.AddCert(certs.ServerCaCert) + tlsConfig.RootCAs = caCertPool + } + if config.EnableMutualTLS { + // Setup mutual TLS - client + tlsConfig.Certificates = []tls.Certificate{certs.ClientKeyPair} + } + transport := &http.Transport{TLSClientConfig: tlsConfig} + httpClient := &http.Client{Transport: transport} + return &httpMTLSClient{baseURL: config.BaseURL, httpClient: httpClient}, nil +} + +func (s *httpMTLSClient) doRequest(ctx context.Context, method, uri string, body io.Reader) httpResponse { + url := s.baseURL + uri + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return newHTTPResponseError(err) + } + req.Header.Set(contentType, applicationJSON) + resp, err := s.httpClient.Do(req) + if err != nil { + return newHTTPResponseError(err) + } + return httpResponse{statusCode: resp.StatusCode, status: resp.Status, body: resp.Body, err: nil} +} diff --git a/internal/keystore/credhub/value_converter.go b/internal/keystore/credhub/value_converter.go new file mode 100644 index 00000000..82dc5fb4 --- /dev/null +++ b/internal/keystore/credhub/value_converter.go @@ -0,0 +1,30 @@ +// Copyright 2023 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package credhub + +import ( + "encoding/base64" + "strings" + "unicode/utf8" +) + +const base64Prefix = "Base64:" + +func bytesToJSONString(bytes []byte, forceBase64 bool) (value string) { + if utf8.Valid(bytes) && !forceBase64 { + strBytes := string(bytes) + if !strings.HasPrefix(strBytes, base64Prefix) { + return string(bytes) + } + } + return base64Prefix + base64.StdEncoding.EncodeToString(bytes) +} + +func jsonStringToBytes(value string) (bytes []byte, err error) { + if strings.HasPrefix(value, base64Prefix) { + return base64.StdEncoding.DecodeString(strings.TrimPrefix(value, base64Prefix)) + } + return []byte(value), nil +} diff --git a/kesconf/config.go b/kesconf/config.go index d9940f43..2495acdf 100644 --- a/kesconf/config.go +++ b/kesconf/config.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/minio/kes/internal/keystore/credhub" "github.com/minio/kms-go/kes" "gopkg.in/yaml.v3" ) @@ -194,6 +195,7 @@ type ymlFile struct { } `yaml:"managed_identity"` } `yaml:"keyvault"` } `yaml:"azure"` + Entrust *struct { KeyControl *struct { Endpoint env[string] `yaml:"endpoint"` @@ -208,6 +210,17 @@ type ymlFile struct { } `yaml:"tls"` } `yaml:"keycontrol"` } `yaml:"entrust"` + + CredHub *struct { + BaseURL env[string] `yaml:"base_url"` + EnableMutualTLS env[bool] `yaml:"enable_mutual_tls"` + ClientCertFilePath env[string] `yaml:"client_cert_file_path"` + ClientKeyFilePath env[string] `yaml:"client_key_file_path"` + ServerCaCertFilePath env[string] `yaml:"server_ca_cert_file_path"` + ServerInsecureSkipVerify env[bool] `yaml:"server_insecure_skip_verify"` + Namespace env[string] `yaml:"namespace"` + ForceBase64ValuesEncoding env[bool] `yaml:"force_base64_values_encoding"` + } `yaml:"credhub"` } `yaml:"keystore"` } @@ -627,6 +640,8 @@ func ymlToKeyStore(y *ymlFile) (KeyStore, error) { } keystore = s } + + // Entrust if y.KeyStore.Entrust != nil && y.KeyStore.Entrust.KeyControl != nil { if keystore != nil { return nil, errors.New("kesconf: invalid keystore config: more than once keystore specified") @@ -656,6 +671,28 @@ func ymlToKeyStore(y *ymlFile) (KeyStore, error) { } } + // CF CredHub + if y.KeyStore.CredHub != nil { + if keystore != nil { + return nil, errors.New("kesconf: invalid CredHub config: more than once keystore specified") + } + config := credhub.Config{ + BaseURL: y.KeyStore.CredHub.BaseURL.Value, + EnableMutualTLS: y.KeyStore.CredHub.EnableMutualTLS.Value, + ClientCertFilePath: y.KeyStore.CredHub.ClientCertFilePath.Value, + ClientKeyFilePath: y.KeyStore.CredHub.ClientKeyFilePath.Value, + ServerInsecureSkipVerify: y.KeyStore.CredHub.ServerInsecureSkipVerify.Value, + ServerCaCertFilePath: y.KeyStore.CredHub.ServerCaCertFilePath.Value, + Namespace: y.KeyStore.CredHub.Namespace.Value, + ForceBase64ValuesEncoding: y.KeyStore.CredHub.ForceBase64ValuesEncoding.Value, + } + _, err := config.Validate() + if err != nil { + return nil, err + } + keystore = &CredHubKeyStore{Config: &config} + } + if keystore == nil { return nil, errors.New("kesconf: no keystore specified") } diff --git a/kesconf/credhub_test.go b/kesconf/credhub_test.go new file mode 100644 index 00000000..94ae3d25 --- /dev/null +++ b/kesconf/credhub_test.go @@ -0,0 +1,40 @@ +// Copyright 2023 - MinIO, Inc. All rights reserved. +// Use of this source code is governed by the AGPLv3 +// license that can be found in the LICENSE file. + +package kesconf_test + +import ( + "flag" + "testing" + + "github.com/minio/kes/kesconf" +) + +var credhubConfigFile = flag.String("credhub.config", "", "Path to a KES config file with CredHub config") + +func TestCredHub(t *testing.T) { + if *credhubConfigFile == "" { + t.Skip("CredHub tests disabled. Use -credhub.config= to enable them") + } + + config, err := kesconf.ReadFile(*credhubConfigFile) + if err != nil { + t.Fatal(err) + } + if _, ok := config.KeyStore.(*kesconf.CredHubKeyStore); !ok { + t.Fatalf("Invalid Keystore: want %T - got %T", config.KeyStore, &kesconf.CredHubKeyStore{}) + } + + ctx, cancel := testingContext(t) + defer cancel() + + store, err := config.KeyStore.Connect(ctx) + if err != nil { + t.Fatal(err) + } + + t.Run("Create", func(t *testing.T) { testCreate(ctx, store, t, RandString(ranStringLength)) }) + t.Run("Get", func(t *testing.T) { testGet(ctx, store, t, RandString(ranStringLength)) }) + t.Run("Status", func(t *testing.T) { testStatus(ctx, store, t) }) +} diff --git a/kesconf/file.go b/kesconf/file.go index c60218d6..7663dcb1 100644 --- a/kesconf/file.go +++ b/kesconf/file.go @@ -22,6 +22,7 @@ import ( "github.com/minio/kes/internal/https" "github.com/minio/kes/internal/keystore/aws" "github.com/minio/kes/internal/keystore/azure" + "github.com/minio/kes/internal/keystore/credhub" "github.com/minio/kes/internal/keystore/entrust" "github.com/minio/kes/internal/keystore/fortanix" "github.com/minio/kes/internal/keystore/fs" @@ -29,7 +30,7 @@ import ( "github.com/minio/kes/internal/keystore/gemalto" "github.com/minio/kes/internal/keystore/vault" kesdk "github.com/minio/kms-go/kes" - yaml "gopkg.in/yaml.v3" + "gopkg.in/yaml.v3" ) // ReadFile opens the given file and reads the KES configuration @@ -39,7 +40,8 @@ func ReadFile(filename string) (*File, error) { if err != nil { return nil, err } - defer f.Close() // make sure to close file in case of panic + // make sure to close file in case of panic + defer func(f *os.File) { _ = f.Close() }(f) file, err := ReadFrom(f) if cErr := f.Close(); err == nil { @@ -828,3 +830,13 @@ func (s *EntrustKeyControlKeyStore) Connect(ctx context.Context) (kes.KeyStore, }, }) } + +// CredHubKeyStore is a structure containing the configuration for CredHub. +type CredHubKeyStore struct { + Config *credhub.Config +} + +// Connect returns a kv.Store that stores key-value pairs on CredHub. +func (s *CredHubKeyStore) Connect(ctx context.Context) (kes.KeyStore, error) { + return credhub.NewStore(ctx, s.Config) +} diff --git a/kesconf/testdata/credhub.yml b/kesconf/testdata/credhub.yml new file mode 100644 index 00000000..3b15e752 --- /dev/null +++ b/kesconf/testdata/credhub.yml @@ -0,0 +1,19 @@ +version: v1 + +admin: + identity: disabled + +tls: + key: ./server.key + cert: ./server.cert + +keystore: + credhub: + base_url: https://localhost:8844 + enable_mutual_tls: true + client_cert_file_path: ./client.cert + client_key_file_path: ./client.key + server_insecure_skip_verify: false + server_ca_cert_file_path: ./server-ca.cert + namespace: /test-namespace + force_base64_values_encoding: false \ No newline at end of file