From 17a15128294660a9a359cbbe425dcc97b40f3107 Mon Sep 17 00:00:00 2001 From: Andreas Auernhammer Date: Tue, 13 Jun 2023 17:37:21 +0200 Subject: [PATCH] prepare SDK for the distributed KES implementation This commit changes the SDK w.r.t. to the upcoming distributed KES server implementation. In particular, the listing code has been changed to a paginated implementation. However, backwards compatibility with existing KES servers is maintained. Signed-off-by: Andreas Auernhammer --- .github/workflows/go.yml | 6 +- client.go | 217 +++++++++++++++++++++-------- dek-example_test.go | 80 ----------- enclave.go | 290 ++++++++++++++++++++------------------- error.go | 23 +++- error_test.go | 3 +- examples_test.go | 142 +++++++++++++++++++ identity.go | 170 ----------------------- iter.go | 144 +++++++++++++++++++ key.go | 229 +++---------------------------- key_test.go | 22 ++- policy.go | 223 +++++------------------------- retry.go | 4 + secret.go | 4 +- secret_test.go | 2 +- 15 files changed, 687 insertions(+), 872 deletions(-) delete mode 100644 dek-example_test.go create mode 100644 examples_test.go create mode 100644 iter.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a28fd40..c95a504 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: 1.20.3 + go-version: 1.20.5 check-latest: true - name: Check out code uses: actions/checkout@v3 @@ -40,7 +40,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: 1.20.3 + go-version: 1.20.5 check-latest: true - name: Check out code uses: actions/checkout@v3 @@ -56,7 +56,7 @@ jobs: uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: - go-version: 1.20.3 + go-version: 1.20.5 check-latest: true - name: Get govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest diff --git a/client.go b/client.go index 68b5239..7de742a 100644 --- a/client.go +++ b/client.go @@ -79,18 +79,17 @@ type Client struct { lb *loadBalancer } -// NewClient returns a new KES client with the given -// KES server endpoint that uses the given TLS certificate -// mTLS authentication. -// -// The TLS certificate must be valid for client authentication. -// -// NewClient uses an http.Transport with reasonable defaults. -func NewClient(endpoint string, cert tls.Certificate) *Client { +// NewClient returns a new KES client that uses an API key +// for authentication. +func NewClient(endpoint string, key APIKey, options ...CertificateOption) (*Client, error) { + cert, err := GenerateCertificate(key, options...) + if err != nil { + return nil, err + } return NewClientWithConfig(endpoint, &tls.Config{ - MinVersion: tls.VersionTLS13, + MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}, - }) + }), nil } // NewClientWithConfig returns a new KES client with the @@ -265,31 +264,88 @@ func (c *Client) APIs(ctx context.Context) ([]API, error) { return apis, nil } -// CreateEnclave creates a new enclave with the given -// identity as enclave admin. Only the KES system -// admin can create new enclaves. -// -// It returns ErrEnclaveExists if the enclave already -// exists. -func (c *Client) CreateEnclave(ctx context.Context, name string, admin Identity) error { +// ExpandCluster expands a KES cluster by adding a new node with +// the given endpoint. +func (c *Client) ExpandCluster(ctx context.Context, endpoint string) error { const ( - APIPath = "/v1/enclave/create" - Method = http.MethodPost + APIPath = "/v1/cluster/expand" + Method = http.MethodPut StatusOK = http.StatusOK ) type Request struct { - Admin Identity `json:"admin"` + Endpoint string `json:"endpoint"` } c.init.Do(c.initLoadBalancer) body, err := json.Marshal(Request{ - Admin: admin, + Endpoint: endpoint, }) if err != nil { return err } + client := retry(c.HTTPClient) - resp, err := c.lb.Send(ctx, &client, Method, c.Endpoints, join(APIPath, name), bytes.NewReader(body)) + resp, err := c.lb.Send(ctx, &client, Method, c.Endpoints, APIPath, bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != StatusOK { + return parseErrorResponse(resp) + } + return nil +} + +// ShrinkCluster shrinks a KES cluster by removingt the new node with +// the given endpoint from the cluster. +func (c *Client) ShrinkCluster(ctx context.Context, endpoint string) error { + const ( + APIPath = "/v1/cluster/shrink" + Method = http.MethodDelete + StatusOK = http.StatusOK + ) + type Request struct { + Endpoint string `json:"endpoint"` + } + c.init.Do(c.initLoadBalancer) + + body, err := json.Marshal(Request{ + Endpoint: endpoint, + }) + if err != nil { + return err + } + + client := retry(c.HTTPClient) + resp, err := c.lb.Send(ctx, &client, Method, c.Endpoints, APIPath, bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != StatusOK { + return parseErrorResponse(resp) + } + return nil +} + +// CreateEnclave creates a new enclave with the given +// identity as enclave admin. Only the KES system +// admin can create new enclaves. +// +// It returns ErrEnclaveExists if the enclave already +// exists. +func (c *Client) CreateEnclave(ctx context.Context, name string) error { + const ( + APIPath = "/v1/enclave/create" + Method = http.MethodPut + StatusOK = http.StatusOK + ) + c.init.Do(c.initLoadBalancer) + + client := retry(c.HTTPClient) + resp, err := c.lb.Send(ctx, &client, Method, c.Endpoints, join(APIPath, name), nil) if err != nil { return err } @@ -321,10 +377,6 @@ func (c *Client) DescribeEnclave(ctx context.Context, name string) (*EnclaveInfo } c.init.Do(c.initLoadBalancer) - if name == "" { - name = "default" - } - client := retry(c.HTTPClient) resp, err := c.lb.Send(ctx, &client, Method, c.Endpoints, join(APIPath, name), nil) if err != nil { @@ -347,6 +399,48 @@ func (c *Client) DescribeEnclave(ctx context.Context, name string) (*EnclaveInfo }, nil } +// ListEnclaves returns a paginated list of enclave names from the server, +// starting at the specified prefix. If n > 0, it returns at most n names. +// Otherwise, the server determines the page size. +// +// ListEnclaves also returns a continuation token for fetching the next batch. +// When the listing reaches the end, the continuation token will be empty. +// +// The ListIter type can be used as a convenient way to iterate over a paginated list. +func (c *Client) ListEnclaves(ctx context.Context, prefix string, n int) ([]string, string, error) { + const ( + APIPath = "/v1/enclave/list" + Method = http.MethodGet + StatusOK = http.StatusOK + MaxResponseSize = 1 * mem.MiB + ) + type Response struct { + Names []string `json:"names"` + ContinueAt string `json:"continue_at"` + } + c.init.Do(c.initLoadBalancer) + + client := retry(c.HTTPClient) + resp, err := c.lb.Send(ctx, &client, Method, c.Endpoints, join(APIPath, prefix), nil) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode != StatusOK { + return nil, "", parseErrorResponse(resp) + } + + var response Response + if err := json.NewDecoder(mem.LimitReader(resp.Body, MaxResponseSize)).Decode(&response); err != nil { + return nil, "", err + } + if n > 0 && n < len(response.Names) { + return response.Names[:n], response.Names[n], nil + } + return response.Names, response.ContinueAt, nil +} + // DeleteEnclave delete the specified enclave. Only the // KES system admin can delete enclaves. // @@ -496,19 +590,21 @@ func (c *Client) DecryptAll(ctx context.Context, name string, ciphertexts ...CCP return enclave.DecryptAll(ctx, name, ciphertexts...) } -// ListKeys lists all names of cryptographic keys that match the given -// pattern. It returns a KeyIterator that iterates over all matched key -// names. +// ListKeys returns a paginated list of key names from the server, +// starting at the specified prefix. If n > 0, it returns at most n names. +// Otherwise, the server determines the page size. +// +// ListKeys also returns a continuation token for fetching the next batch. +// When the listing reaches the end, the continuation token will be empty. // -// The pattern matching happens on the server side. If pattern is empty -// the KeyIterator iterates over all key names. -func (c *Client) ListKeys(ctx context.Context, pattern string) (*KeyIterator, error) { +// The ListIter type can be used as a convenient way to iterate over a paginated list. +func (c *Client) ListKeys(ctx context.Context, prefix string, n int) ([]string, string, error) { enclave := Enclave{ Endpoints: c.Endpoints, HTTPClient: c.HTTPClient, lb: c.lb, } - return enclave.ListKeys(ctx, pattern) + return enclave.ListKeys(ctx, prefix, n) } // CreateSecret creates a new secret with the given name. @@ -560,31 +656,33 @@ func (c *Client) DeleteSecret(ctx context.Context, name string) error { return enclave.DeleteSecret(ctx, name) } -// ListSecrets returns a SecretIter that iterates over all secrets -// matching the pattern. +// ListSecrets returns a paginated list of secret names from the server, +// starting at the specified prefix. If n > 0, it returns at most n names. +// Otherwise, the server determines the page size. +// +// ListSecrets also returns a continuation token for fetching the next batch. +// When the listing reaches the end, the continuation token will be empty. // -// The '*' pattern matches any secret. If pattern is empty the -// SecretIter iterates over all secrets names. -func (c *Client) ListSecrets(ctx context.Context, pattern string) (*SecretIter, error) { +// The ListIter type can be used as a convenient way to iterate over a paginated list. +func (c *Client) ListSecrets(ctx context.Context, prefix string, n int) ([]string, string, error) { enclave := Enclave{ Endpoints: c.Endpoints, HTTPClient: c.HTTPClient, lb: c.lb, } - return enclave.ListSecrets(ctx, pattern) + return enclave.ListSecrets(ctx, prefix, n) } -// SetPolicy creates the given policy. If a policy with the same -// name already exists, SetPolicy overwrites the existing policy -// with the given one. Any existing identites will be assigned to -// the given policy. -func (c *Client) SetPolicy(ctx context.Context, name string, policy *Policy) error { +// CreatePolicy creates a new policy. +// +// It returns ErrPolicyExists if such a policy already exists. +func (c *Client) CreatePolicy(ctx context.Context, name string, policy *Policy) error { enclave := Enclave{ Endpoints: c.Endpoints, HTTPClient: c.HTTPClient, lb: c.lb, } - return enclave.SetPolicy(ctx, name, policy) + return enclave.CreatePolicy(ctx, name, policy) } // DescribePolicy returns the PolicyInfo for the given policy. @@ -623,18 +721,21 @@ func (c *Client) DeletePolicy(ctx context.Context, name string) error { return enclave.DeletePolicy(ctx, name) } -// ListPolicies lists all policy names that match the given pattern. -// It returns a PolicyIterator that iterates over all matched policies. +// ListPolicies returns a paginated list of policy names from the server, +// starting at the specified prefix. If n > 0, it returns at most n names. +// Otherwise, the server determines the page size. +// +// ListPolicies also returns a continuation token for fetching the next batch. +// When the listing reaches the end, the continuation token will be empty. // -// The pattern matching happens on the server side. If pattern is empty -// ListPolicies returns all policy names. -func (c *Client) ListPolicies(ctx context.Context, pattern string) (*PolicyIterator, error) { +// The ListIter type can be used as a convenient way to iterate over a paginated list. +func (c *Client) ListPolicies(ctx context.Context, prefix string, n int) ([]string, string, error) { enclave := Enclave{ Endpoints: c.Endpoints, HTTPClient: c.HTTPClient, lb: c.lb, } - return enclave.ListPolicies(ctx, pattern) + return enclave.ListPolicies(ctx, prefix, n) } // AssignPolicy assigns the policy to the identity. @@ -690,17 +791,21 @@ func (c *Client) DeleteIdentity(ctx context.Context, identity Identity) error { return enclave.DeleteIdentity(ctx, identity) } -// ListIdentities lists all identites that match the given pattern. +// ListIdentities returns a paginated list of identities from the server, +// starting at the specified prefix. If n > 0, it returns at most n identities. +// Otherwise, the server determines the page size. +// +// ListIdentities also returns a continuation token for fetching the next batch. +// When the listing reaches the end, the continuation token will be empty. // -// The pattern matching happens on the server side. If pattern is empty -// ListIdentities returns all identities. -func (c *Client) ListIdentities(ctx context.Context, pattern string) (*IdentityIterator, error) { +// The ListIter type can be used as a convenient way to iterate over a paginated list. +func (c *Client) ListIdentities(ctx context.Context, prefix string, n int) ([]Identity, string, error) { enclave := Enclave{ Endpoints: c.Endpoints, HTTPClient: c.HTTPClient, lb: c.lb, } - return enclave.ListIdentities(ctx, pattern) + return enclave.ListIdentities(ctx, prefix, n) } // AuditLog returns a stream of audit events produced by the diff --git a/dek-example_test.go b/dek-example_test.go deleted file mode 100644 index 11a24f4..0000000 --- a/dek-example_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// 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 kes_test - -import ( - "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/tls" - "io" - "log" - - "github.com/minio/kes-go" -) - -func ExampleClient_GenerateKey() { - // First, load the client TLS private key / certificate to - // authenticate against the KES server. - const ( - keyFile = "./root.key" - certFile = "./root.cert" - ) - certificate, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - log.Fatalf("Failed to load TLS certificate for client (mTLS) authentication: %v", err) - } - - // Then, generate a new data encryption key (DEK). The DEK contains a - // plaintext key as well as a ciphertext version. The ciphertext is the - // plaintext key encrypted by the KES server with the key named 'keyName'. - // Only the KES server can decrypt the ciphertext key. - const ( - endpoint = "https://play.min.io:7373" - keyName = "my-key" - ) - client := kes.NewClient(endpoint, certificate) - key, err := client.GenerateKey(context.Background(), keyName, nil) - if err != nil { - log.Fatalf("Failed to generate a new data encryption key: %v", err) - } - - // Finally, use AES-GCM to encrypt a short message using the plaintext key. - // The actual ciphertext, the encrypted key, the nonce and the associated data - // can be stored on some untrusted location. The ciphertext can only be decrypted - // by contacting the KES server - once the plaintext key is no longer accessible. - block, err := aes.NewCipher(key.Plaintext) - if err != nil { - log.Fatalf("Failed to create AES instance: %v", err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - log.Fatalf("Failed to create AES-GCM instance: %v", err) - } - - var ( - message = []byte("Hello World") - nonce = mustRandom(rand.Reader, gcm.NonceSize()) - associatedData = []byte("my-file.text") - ) - ciphertext := gcm.Seal(nil, nonce, message, associatedData) - - // Now store the ciphertext as well as the key.Ciphertext, the nonce - // and the associatedData. The key.Ciphertext contains the encrypted - // version of the key used to encrypt the message. - // It needs to be sent to the KES server to obtain the plaintext key - // which is needed to decrypt the ciphertext (using the nonce and - // associatedData) and obtain the message again. - _, _, _, _ = ciphertext, key.Ciphertext, nonce, associatedData -} - -func mustRandom(random io.Reader, size int) []byte { - v := make([]byte, size) - if _, err := io.ReadFull(random, v); err != nil { - panic(err) - } - return v -} diff --git a/enclave.go b/enclave.go index 45e2b43..d877160 100644 --- a/enclave.go +++ b/enclave.go @@ -11,21 +11,12 @@ import ( "encoding/json" "net" "net/http" - "net/url" - "path" "sync" "time" "aead.dev/mem" ) -// EnclaveInfo describes a KES enclave. -type EnclaveInfo struct { - Name string `json:"name"` // Enclave name - CreatedAt time.Time `json:"created_at"` // Point in time when the enclave has been created - CreatedBy Identity `json:"created_by"` // Identity that created the enclave -} - // An Enclave is an isolated area within a KES server. // It stores cryptographic keys, policies and other // related information securely. @@ -60,19 +51,26 @@ type Enclave struct { lb *loadBalancer } -// NewEnclave returns a new Enclave with the given name and -// KES server endpoint that uses the given TLS certificate -// mTLS authentication. -// -// The TLS certificate must be valid for client authentication. -// -// NewEnclave uses an http.Transport with reasonable defaults. +// EnclaveInfo describes a KES enclave. +type EnclaveInfo struct { + Name string + CreatedAt time.Time // Point in time when the enclave has been created + CreatedBy Identity // Identity that created the enclave +} + +// NewEnclave returns a new Enclave that uses an API key +// for authentication. // -// For getting an Enclave from a Client refer to Client.Enclave. -func NewEnclave(endpoint, name string, certificate tls.Certificate) *Enclave { +// For obtaining an Enclave from a Client refer to Client.Enclave. +func NewEnclave(endpoint, name string, key APIKey, options ...CertificateOption) (*Enclave, error) { + cert, err := GenerateCertificate(key, options...) + if err != nil { + return nil, err + } return NewEnclaveWithConfig(endpoint, name, &tls.Config{ - Certificates: []tls.Certificate{certificate}, - }) + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }), nil } // NewEnclaveWithConfig returns a new Enclave with the given @@ -123,7 +121,7 @@ func (e *Enclave) CreateKey(ctx context.Context, name string) error { e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return err } @@ -155,7 +153,7 @@ func (e *Enclave) ImportKey(ctx context.Context, name string, key []byte) error } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) if err != nil { return err } @@ -185,7 +183,7 @@ func (e *Enclave) DescribeKey(ctx context.Context, name string) (*KeyInfo, error e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return nil, err } @@ -219,7 +217,7 @@ func (e *Enclave) DeleteKey(ctx context.Context, name string) error { e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return err } @@ -274,7 +272,7 @@ func (e *Enclave) GenerateKey(ctx context.Context, name string, context []byte) } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) if err != nil { return DEK{}, err } @@ -324,7 +322,7 @@ func (e *Enclave) Encrypt(ctx context.Context, name string, plaintext, context [ } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) if err != nil { return nil, err } @@ -373,7 +371,7 @@ func (e *Enclave) Decrypt(ctx context.Context, name string, ciphertext, context } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) if err != nil { return nil, err } @@ -429,7 +427,7 @@ func (e *Enclave) DecryptAll(ctx context.Context, name string, ciphertexts ...CC return nil, err } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) if err != nil { return nil, err } @@ -460,31 +458,38 @@ func (e *Enclave) DecryptAll(ctx context.Context, name string, ciphertexts ...CC // // The pattern matching happens on the server side. If pattern is empty // the KeyIterator iterates over all key names. -func (e *Enclave) ListKeys(ctx context.Context, pattern string) (*KeyIterator, error) { +func (e *Enclave) ListKeys(ctx context.Context, prefix string, n int) ([]string, string, error) { const ( - APIPath = "/v1/key/list" - Method = http.MethodGet - StatusOK = http.StatusOK + APIPath = "/v1/key/list" + Method = http.MethodGet + StatusOK = http.StatusOK + MaxResponseSize = 1 * mem.MiB ) - e.init.Do(e.initLoadBalancer) - - if pattern == "" { // The empty pattern never matches anything - const MatchAll = "*" - pattern = MatchAll + type Response struct { + Names []string `json:"names"` + ContinueAt string `json:"continue_at"` } + e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, pattern), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, prefix), nil) if err != nil { - return nil, err + return nil, "", err } + defer resp.Body.Close() + if resp.StatusCode != StatusOK { - return nil, parseErrorResponse(resp) + return nil, "", parseErrorResponse(resp) } - return &KeyIterator{ - decoder: json.NewDecoder(resp.Body), - closer: resp.Body, - }, nil + + if resp.Header.Get("Content-Type") == "application/x-ndjson" { + return parseLegacyListing(resp.Body, n) + } + var response Response + if err = json.NewDecoder(mem.LimitReader(resp.Body, MaxResponseSize)).Decode(&response); err != nil { + return nil, "", err + } + return response.Names, response.ContinueAt, nil } // CreateSecret creates a new secret with the given name. @@ -500,13 +505,13 @@ func (e *Enclave) CreateSecret(ctx context.Context, name string, value []byte, o e.init.Do(e.initLoadBalancer) type Request struct { - Bytes []byte `json:"bytes"` - Type SecretType `json:"type,omitempty"` + Secret []byte `json:"secret"` + Type SecretType `json:"type,omitempty"` } req := Request{ - Bytes: value, - Type: SecretGeneric, + Secret: value, + Type: SecretGeneric, } if options != nil { req.Type = options.Type @@ -517,7 +522,7 @@ func (e *Enclave) CreateSecret(ctx context.Context, name string, value []byte, o } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body)) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body)) if err != nil { return err } @@ -549,7 +554,7 @@ func (e *Enclave) DescribeSecret(ctx context.Context, name string) (*SecretInfo, } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return nil, err } @@ -592,7 +597,7 @@ func (e *Enclave) ReadSecret(ctx context.Context, name string) ([]byte, *SecretI } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return nil, nil, err } @@ -626,7 +631,7 @@ func (e *Enclave) DeleteSecret(ctx context.Context, name string) error { e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return err } @@ -643,32 +648,37 @@ func (e *Enclave) DeleteSecret(ctx context.Context, name string) error { // // The '*' pattern matches any secret. If pattern is empty the // SecretIter iterates over all secrets names. -func (e *Enclave) ListSecrets(ctx context.Context, pattern string) (*SecretIter, error) { +func (e *Enclave) ListSecrets(ctx context.Context, prefix string, n int) ([]string, string, error) { const ( - APIPath = "/v1/secret/list" - Method = http.MethodGet - StatusOK = http.StatusOK + APIPath = "/v1/secret/list" + Method = http.MethodGet + StatusOK = http.StatusOK + MaxResponseSize = 1 * mem.MiB ) - e.init.Do(e.initLoadBalancer) - - if pattern == "" { // The empty pattern never matches anything - const MatchAll = "*" - pattern = MatchAll + type Response struct { + Names []string `json:"names"` + ContinueAt string `json:"continue_at"` } + e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, pattern), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, prefix), nil) if err != nil { - return nil, err + return nil, "", err } if resp.StatusCode != StatusOK { - return nil, parseErrorResponse(resp) + return nil, "", parseErrorResponse(resp) } - return &SecretIter{ - decoder: json.NewDecoder(resp.Body), - closer: resp.Body, - }, nil + + if resp.Header.Get("Content-Type") == "application/x-ndjson" { + return parseLegacyListing(resp.Body, n) + } + var response Response + if err = json.NewDecoder(mem.LimitReader(resp.Body, MaxResponseSize)).Decode(&response); err != nil { + return nil, "", err + } + return response.Names, response.ContinueAt, nil } // AssignPolicy assigns the policy to the identity. @@ -693,7 +703,7 @@ func (e *Enclave) AssignPolicy(ctx context.Context, policy string, identity Iden return err } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, policy), bytes.NewReader(body)) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, policy), bytes.NewReader(body)) if err != nil { return err } @@ -705,13 +715,12 @@ func (e *Enclave) AssignPolicy(ctx context.Context, policy string, identity Iden return nil } -// SetPolicy creates the given policy. If a policy with the same -// name already exists, SetPolicy overwrites the existing policy -// with the given one. Any existing identites will be assigned to -// the given policy. -func (e *Enclave) SetPolicy(ctx context.Context, name string, policy *Policy) error { +// CreatePolicy creates a new policy. +// +// It returns ErrPolicyExists if such a policy already exists. +func (e *Enclave) CreatePolicy(ctx context.Context, name string, policy *Policy) error { const ( - APIPath = "/v1/policy/write" + APIPath = "/v1/policy/create" Method = http.MethodPost StatusOK = http.StatusOK ) @@ -722,7 +731,7 @@ func (e *Enclave) SetPolicy(ctx context.Context, name string, policy *Policy) er return err } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), bytes.NewReader(body), withHeader("Content-Type", "application/json")) if err != nil { return err } @@ -750,7 +759,7 @@ func (e *Enclave) DescribePolicy(ctx context.Context, name string) (*PolicyInfo, CreatedBy Identity `json:"created_by"` } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return nil, err } @@ -784,13 +793,13 @@ func (e *Enclave) GetPolicy(ctx context.Context, name string) (*Policy, error) { e.init.Do(e.initLoadBalancer) type Response struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` - CreatedAt time.Time `json:"created_at"` - CreatedBy Identity `json:"created_by"` + Allow map[string]Rule `json:"allow"` + Deny map[string]Rule `json:"deny"` + CreatedAt time.Time `json:"created_at"` + CreatedBy Identity `json:"created_by"` } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return nil, err } @@ -828,7 +837,7 @@ func (e *Enclave) DeletePolicy(ctx context.Context, name string) error { e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, name), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, name), nil) if err != nil { return err } @@ -844,31 +853,36 @@ func (e *Enclave) DeletePolicy(ctx context.Context, name string) error { // // The pattern matching happens on the server side. If pattern is empty // ListPolicies returns all policy names. -func (e *Enclave) ListPolicies(ctx context.Context, pattern string) (*PolicyIterator, error) { +func (e *Enclave) ListPolicies(ctx context.Context, prefix string, n int) ([]string, string, error) { const ( - APIPath = "/v1/policy/list" - Method = http.MethodGet - StatusOK = http.StatusOK + APIPath = "/v1/policy/list" + Method = http.MethodGet + StatusOK = http.StatusOK + MaxResponseSize = 1 * mem.MiB ) - e.init.Do(e.initLoadBalancer) - - if pattern == "" { // The empty pattern never matches anything - const MatchAll = "*" - pattern = MatchAll + type Response struct { + Names []string `json:"names"` + ContinueAt string `json:"continue_at"` } + e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, pattern), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, prefix), nil) if err != nil { - return nil, err + return nil, "", err } if resp.StatusCode != StatusOK { - return nil, parseErrorResponse(resp) + return nil, "", parseErrorResponse(resp) } - return &PolicyIterator{ - decoder: json.NewDecoder(resp.Body), - closer: resp.Body, - }, nil + + if resp.Header.Get("Content-Type") == "application/x-ndjson" { + return parseLegacyListing(resp.Body, n) + } + var response Response + if err = json.NewDecoder(mem.LimitReader(resp.Body, MaxResponseSize)).Decode(&response); err != nil { + return nil, "", err + } + return response.Names, response.ContinueAt, nil } // DescribeIdentity returns an IdentityInfo describing the given identity. @@ -889,7 +903,7 @@ func (e *Enclave) DescribeIdentity(ctx context.Context, identity Identity) (*Ide } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, identity.String()), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, identity.String()), nil) if err != nil { return nil, err } @@ -926,23 +940,19 @@ func (e *Enclave) DescribeSelf(ctx context.Context) (*IdentityInfo, *Policy, err ) e.init.Do(e.initLoadBalancer) - type InlinePolicy struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` + type Response struct { + Identity Identity `json:"identity"` + IsAdmin bool `json:"admin"` CreatedAt time.Time `json:"created_at"` CreatedBy Identity `json:"created_by"` - } - type Response struct { - Identity Identity `json:"identity"` - IsAdmin bool `json:"admin"` - PolicyName string `json:"policy_name"` - CreatedAt time.Time `json:"created_at"` - CreatedBy Identity `json:"created_by"` - Policy InlinePolicy `json:"policy"` + + Policy string `json:"policy"` + Allow map[string]Rule `json:"allow"` + Deny map[string]Rule `json:"deny"` } client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, APIPath, nil) if err != nil { return nil, nil, err } @@ -957,19 +967,14 @@ func (e *Enclave) DescribeSelf(ctx context.Context) (*IdentityInfo, *Policy, err } info := &IdentityInfo{ Identity: response.Identity, - Policy: response.PolicyName, + Policy: response.Policy, CreatedAt: response.CreatedAt, CreatedBy: response.CreatedBy, IsAdmin: response.IsAdmin, } policy := &Policy{ - Allow: response.Policy.Allow, - Deny: response.Policy.Deny, - Info: PolicyInfo{ - Name: response.PolicyName, - CreatedAt: response.Policy.CreatedAt, - CreatedBy: response.Policy.CreatedBy, - }, + Allow: response.Allow, + Deny: response.Deny, } return info, policy, nil } @@ -988,7 +993,7 @@ func (e *Enclave) DeleteIdentity(ctx context.Context, identity Identity) error { e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, identity.String()), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, identity.String()), nil) if err != nil { return err } @@ -1004,40 +1009,41 @@ func (e *Enclave) DeleteIdentity(ctx context.Context, identity Identity) error { // // The pattern matching happens on the server side. If pattern is empty // ListIdentities returns all identities. -func (e *Enclave) ListIdentities(ctx context.Context, pattern string) (*IdentityIterator, error) { +func (e *Enclave) ListIdentities(ctx context.Context, prefix string, n int) ([]Identity, string, error) { const ( - APIPath = "/v1/identity/list" - Method = http.MethodGet - StatusOK = http.StatusOK + APIPath = "/v1/identity/list" + Method = http.MethodGet + StatusOK = http.StatusOK + MaxResponseSize = 1 * mem.MiB ) + type Response struct { + Names []Identity `json:"identities"` + ContinueAt string `json:"continue_at"` + } e.init.Do(e.initLoadBalancer) client := retry(e.HTTPClient) - resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, e.path(APIPath, pattern), nil) + resp, err := e.lb.Send(ctx, &client, Method, e.Endpoints, join(APIPath, prefix), nil) if err != nil { - return nil, err + return nil, "", err } if resp.StatusCode != StatusOK { - return nil, parseErrorResponse(resp) + return nil, "", parseErrorResponse(resp) } - return &IdentityIterator{ - decoder: json.NewDecoder(resp.Body), - closer: resp.Body, - }, nil + + if resp.Header.Get("Content-Type") == "application/x-ndjson" { + return parseLegacyIdentityListing(resp.Body, n) + } + var response Response + if err = json.NewDecoder(mem.LimitReader(resp.Body, MaxResponseSize)).Decode(&response); err != nil { + return nil, "", err + } + return response.Names, response.ContinueAt, nil } func (e *Enclave) initLoadBalancer() { if e.lb == nil { e.lb = &loadBalancer{endpoints: map[string]time.Time{}} + e.lb.enclave = e.Name } } - -func (e *Enclave) path(api string, args ...string) string { - for _, arg := range args { - api = path.Join(api, url.PathEscape(arg)) - } - if e.Name != "" { - api += "?enclave=" + url.QueryEscape(e.Name) - } - return api -} diff --git a/error.go b/error.go index 0625b00..89b5dfb 100644 --- a/error.go +++ b/error.go @@ -19,15 +19,12 @@ import ( // KES server API errors var ( - // ErrSealed is returned by a KES server that got sealed. - // Such a KES server will not process any requests until - // unsealed again. - ErrSealed = NewError(http.StatusForbidden, "system is sealed") - // ErrNotAllowed is returned by a KES server when a client has // not sufficient permission to perform the API operation. ErrNotAllowed = NewError(http.StatusForbidden, "not authorized: insufficient permissions") + ErrPartialWrite = NewError(http.StatusServiceUnavailable, "change committed but not replicated") + // ErrKeyNotFound is returned by a KES server when a client tries to // access or use a cryptographic key which does not exist. ErrKeyNotFound = NewError(http.StatusNotFound, "key does not exist") @@ -40,14 +37,26 @@ var ( // access a secret which does not exist. ErrSecretNotFound = NewError(http.StatusNotFound, "secret does not exist") + // ErrSecretVersionNotFound is returned by a KES server when a client tries to + // access a secret version which does not exist. + ErrSecretVersionNotFound = NewError(http.StatusNotFound, "secret version does not exist") + // ErrKeyExists is returned by a KES server when a client tries // to create a secret which already exists. ErrSecretExists = NewError(http.StatusNotFound, "secret already exists") + // ErrPolicyExists is returned by a KES server when a client tries + // to create a policy which already exists. + ErrPolicyExists = NewError(http.StatusBadRequest, "policy already exists") + // ErrPolicyNotFound is returned by a KES server when a client // tries to access a policy which does not exist. ErrPolicyNotFound = NewError(http.StatusNotFound, "policy does not exist") + // ErrIdentityExists is returned by a KES server when a client tries + // to create an identity which already exists. + ErrIdentityExists = NewError(http.StatusBadRequest, "identity already exists") + // ErrPolicyNotFound is returned by a KES server when a client // tries to access a policy which does not exist. ErrIdentityNotFound = NewError(http.StatusNotFound, "identity does not exist") @@ -174,11 +183,15 @@ func parseErrorResponse(resp *http.Response) error { if strings.HasPrefix(contentType, "application/json") { type Response struct { Message string `json:"message"` + Error string `json:"error"` } var response Response if err := json.NewDecoder(mem.LimitReader(resp.Body, size)).Decode(&response); err != nil { return err } + if response.Error != "" { + response.Message = response.Error + } // TODO(aead): Remove the backwards-compatibility error checks once enough of the // KES server ecosystem has updated. diff --git a/error_test.go b/error_test.go index 1692266..5e8d741 100644 --- a/error_test.go +++ b/error_test.go @@ -5,6 +5,7 @@ package kes import ( + "errors" "fmt" "io" "net/http" @@ -26,7 +27,7 @@ var newErrorTests = []struct { func TestNewError(t *testing.T) { for i, test := range newErrorTests { err := NewError(test.Code, test.Message) - if err != test.Err { + if !errors.Is(err, test.Err) { t.Fatalf("Test %d: got %v - want %v", i, err, test.Err) } } diff --git a/examples_test.go b/examples_test.go new file mode 100644 index 0000000..d765e62 --- /dev/null +++ b/examples_test.go @@ -0,0 +1,142 @@ +// 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 kes_test + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "fmt" + "io" + "log" + + "github.com/minio/kes-go" +) + +func ExampleNewClient() { + const ( + Endpoint = "https://play.min.io:7373" + APIKey = "kes:v1:AD9E7FSYWrMD+VjhI6q545cYT9YOyFxZb7UnjEepYDRc" + ) + + key, err := kes.ParseAPIKey(APIKey) + if err != nil { + log.Fatalf("Invalid API key '%s': %v", APIKey, err) + } + client, err := kes.NewClient(Endpoint, key) + if err != nil { + log.Fatalf("Failed to create client for '%s': %v", Endpoint, err) + } + _ = client + + fmt.Println("Identity:", key.Identity()) + // Output: + // Identity: 3ecfcdf38fcbe141ae26a1030f81e96b753365a46760ae6b578698a97c59fd22 +} + +func ExampleNewClientWithConfig() { + const ( + Endpoint = "https://play.min.io:7373" + ) + const ( + PrivateKey = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEID9E7FSYWrMD+VjhI6q545cYT9YOyFxZb7UnjEepYDRc\n-----END PRIVATE KEY-----" + Certificate = "-----BEGIN CERTIFICATE-----\nMIIBKDCB26ADAgECAhB6vebGMUfKnmBKyqoApRSOMAUGAytlcDAbMRkwFwYDVQQDDBByb290QHBsYXkubWluLmlvMB4XDTIwMDQzMDE1MjIyNVoXDTI1MDQyOTE1MjIyNVowGzEZMBcGA1UEAwwQcm9vdEBwbGF5Lm1pbi5pbzAqMAUGAytlcAMhALzn735WfmSH/ghKs+4iPWziZMmWdiWr/sqvqeW+WwSxozUwMzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAFBgMrZXADQQDZOrGKb2ATkDlu2pTcP3LyhSBDpYh7V4TvjRkBTRgjkacCzwFLm+mh+7US8V4dBpIDsJ4uuWoF0y6vbLVGIlkG\n-----END CERTIFICATE-----" + ) + + cert, err := tls.X509KeyPair([]byte(Certificate), []byte(PrivateKey)) + if err != nil { + log.Fatalf("Failed to certificate/private key: %v", err) + } + cert.Leaf, _ = x509.ParseCertificate(cert.Certificate[0]) + + client := kes.NewClientWithConfig(Endpoint, &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }) + _ = client + + h := sha256.Sum256(cert.Leaf.RawSubjectPublicKeyInfo) + fmt.Println("Identity:", hex.EncodeToString(h[:])) + // Output: + // Identity: 3ecfcdf38fcbe141ae26a1030f81e96b753365a46760ae6b578698a97c59fd22 +} + +func ExampleClient_Enclave() { + const ( + Endpoint = "https://play.min.io:7373" + APIKey = "kes:v1:AD9E7FSYWrMD+VjhI6q545cYT9YOyFxZb7UnjEepYDRc" + ) + + key, err := kes.ParseAPIKey(APIKey) + if err != nil { + log.Fatalf("Invalid API key '%s': %v", APIKey, err) + } + client, err := kes.NewClient(Endpoint, key) + if err != nil { + log.Fatalf("Failed to create client for '%s': %v", Endpoint, err) + } + enclave := client.Enclave("my-enclave") + _ = enclave + + fmt.Println("Identity:", key.Identity()) + // Output: + // Identity: 3ecfcdf38fcbe141ae26a1030f81e96b753365a46760ae6b578698a97c59fd22 +} + +func ExampleListIter() { + const ( + Endpoint = "https://play.min.io:7373" + APIKey = "kes:v1:AD9E7FSYWrMD+VjhI6q545cYT9YOyFxZb7UnjEepYDRc" + ) + + key, err := kes.ParseAPIKey(APIKey) + if err != nil { + log.Fatalf("Invalid API key '%s': %v", APIKey, err) + } + client, err := kes.NewClient(Endpoint, key) + if err != nil { + log.Fatalf("Failed to create client for '%s': %v", Endpoint, err) + } + + ctx := context.TODO() + iter := kes.ListIter[string]{ + NextFunc: client.ListKeys, + } + for name, err := iter.Next(ctx); err != io.EOF; name, err = iter.Next(ctx) { + if err != nil { + log.Fatalf("Failed to list keys: %v", err) + } + fmt.Println(name) + } +} + +func ExampleListIter_SeekTo() { + const ( + Endpoint = "https://play.min.io:7373" + APIKey = "kes:v1:AD9E7FSYWrMD+VjhI6q545cYT9YOyFxZb7UnjEepYDRc" + ) + + key, err := kes.ParseAPIKey(APIKey) + if err != nil { + log.Fatalf("Invalid API key '%s': %v", APIKey, err) + } + client, err := kes.NewClient(Endpoint, key) + if err != nil { + log.Fatalf("Failed to create client for '%s': %v", Endpoint, err) + } + + ctx := context.TODO() + iter := kes.ListIter[string]{ + NextFunc: client.ListKeys, + } + for name, err := iter.SeekTo(ctx, "my-key"); err != io.EOF; name, err = iter.Next(ctx) { + if err != nil { + log.Fatalf("Failed to list keys: %v", err) + } + fmt.Println(name) + } +} diff --git a/identity.go b/identity.go index fc1cb33..a3ca25e 100644 --- a/identity.go +++ b/identity.go @@ -5,9 +5,6 @@ package kes import ( - "encoding/json" - "errors" - "io" "time" ) @@ -39,170 +36,3 @@ type IdentityInfo struct { CreatedAt time.Time // Point in time when the identity was created CreatedBy Identity // Identity that created the identity } - -// IdentityIterator iterates over a stream of IdentityInfo objects. -// Close the IdentityIterator to release associated resources. -type IdentityIterator struct { - decoder *json.Decoder - closer io.Closer - - current IdentityInfo - err error - closed bool -} - -// Value returns the current IdentityInfo. It remains valid -// until Next is called again. -func (i *IdentityIterator) Value() IdentityInfo { return i.current } - -// Identity returns the current identity. It is a short-hand -// for Value().Identity. -func (i *IdentityIterator) Identity() Identity { return i.current.Identity } - -// Policy returns the policy assigned to the current identity. -// It is a short-hand for Value().Policy. -func (i *IdentityIterator) Policy() string { return i.current.Policy } - -// CreatedAt returns the created-at timestamp of the current -// identity. It is a short-hand for Value().CreatedAt. -func (i *IdentityIterator) CreatedAt() time.Time { return i.current.CreatedAt } - -// CreatedBy returns the identiy that created the current identity. -// It is a short-hand for Value().CreatedBy. -func (i *IdentityIterator) CreatedBy() Identity { return i.current.CreatedBy } - -// Next returns true if there is another IdentityInfo. -// It returns false if there are no more IdentityInfo -// objects or when the IdentityIterator encounters an -// error. -func (i *IdentityIterator) Next() bool { - type Response struct { - Identity Identity `json:"identity"` - IsAdmin bool `json:"admin"` - Policy string `json:"policy"` - CreatedAt time.Time `json:"created_at"` - CreatedBy Identity `json:"created_by"` - - Err string `json:"error"` - } - - if i.closed || i.err != nil { - return false - } - var resp Response - if err := i.decoder.Decode(&resp); err != nil { - if errors.Is(err, io.EOF) { - i.err = i.Close() - } else { - i.err = err - } - return false - } - if resp.Err != "" { - i.err = errors.New(resp.Err) - return false - } - - i.current = IdentityInfo{ - Identity: resp.Identity, - Policy: resp.Policy, - CreatedAt: resp.CreatedAt, - CreatedBy: resp.CreatedBy, - } - return true -} - -// WriteTo encodes and writes all remaining IdentityInfos -// from its current iterator position to w. It returns -// the number of bytes written to w and the first error -// encounterred, if any. -func (i *IdentityIterator) WriteTo(w io.Writer) (int64, error) { - type Response struct { - Identity Identity `json:"identity"` - Admin bool `json:"admin"` - Policy string `json:"policy,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - CreatedBy Identity `json:"created_by,omitempty"` - - Err string `json:"error,omitempty"` - } - if i.err != nil { - return 0, i.err - } - if i.closed { - return 0, errors.New("kes: WriteTo called after Close") - } - - cw := countWriter{W: w} - encoder := json.NewEncoder(&cw) - for { - var resp Response - if err := i.decoder.Decode(&resp); err != nil { - if errors.Is(err, io.EOF) { - i.err = i.Close() - } else { - i.err = err - } - return cw.N, i.err - } - if resp.Err != "" { - i.err = errors.New(resp.Err) - return cw.N, i.err - } - if err := encoder.Encode(resp); err != nil { - i.err = err - return cw.N, err - } - } -} - -// Values returns up to the next n IdentityInfo values. Subsequent -// calls will yield further IdentityInfos if there are any. -// -// If n > 0, Values returns at most n IdentityInfo structs. In this case, -// if Values returns an empty slice, it will return an error explaining -// why. At the end of the listing, the error is io.EOF. -// -// If n <= 0, Values returns all remaining IdentityInfo records. In this -// case, Values always closes the IdentityIterator. When it succeeds, it -// returns a nil error, not io.EOF. -func (i *IdentityIterator) Values(n int) ([]IdentityInfo, error) { - values := []IdentityInfo{} - if n > 0 && i.closed { - return values, io.EOF // Return early, don't alloc a slice - } - if n > 0 { - values = make([]IdentityInfo, 0, n) - } - - var count int - for i.Next() { - values = append(values, i.Value()) - count++ - - if n > 0 && count >= n { - return values, nil - } - } - if err := i.Close(); err != nil { - return values, err - } - if n > 0 && len(values) == 0 { // As by doc contract - return values, io.EOF - } - return values, nil -} - -// Close closes the IdentityIterator and releases -// any associated resources -func (i *IdentityIterator) Close() error { - if !i.closed { - err := i.closer.Close() - if i.err == nil { - i.err = err - } - i.closed = true - return err - } - return i.err -} diff --git a/iter.go b/iter.go new file mode 100644 index 0000000..fa4a3f6 --- /dev/null +++ b/iter.go @@ -0,0 +1,144 @@ +// 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 kes + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "sort" +) + +// ListIter is a struct that represents an iterator over a paginated list +// of items. It is generic over some item type T. +type ListIter[T any] struct { + // NextFunc is a function that retrieves the next batch of items + // from the paginated list. It takes a context, a token to continue + // from, and the maximum number of items to fetch. It returns the + // fetched items, a token to continue from for the next batch, and + // any error encountered. + NextFunc func(context.Context, string, int) ([]T, string, error) + + items []T + continueAt string + err error +} + +// SeekTo seeks to a specific position in the paginated list. It updates the +// iterator's state to start fetching items from the specified prefix and returns +// the subsequent item. +// +// If the iterator has encountered an error previously, it returns the error +// without modifying the state. It returns io.EOF when seeking beyond the end +// of the list. +func (i *ListIter[T]) SeekTo(ctx context.Context, prefix string) (item T, err error) { + if i.err != nil && i.err != io.EOF { + return item, i.err + } + i.continueAt = prefix + + // Clear the items slice and err to start fetching new items. + i.items = []T{} + i.err = nil + + // Fetch the next item after seeking to the specified prefix. + item, err = i.Next(ctx) + return +} + +// Next retrieves the next item from the paginated list. It uses +// the provided context when fetching the next page. If the +// ListIter has encountered an error previously or reached the end +// of the list, it returns the encountered error or io.EOF. +func (i *ListIter[T]) Next(ctx context.Context) (item T, err error) { + if len(i.items) == 0 { + if i.err != nil { + return item, i.err + } + + i.items, i.continueAt, i.err = i.NextFunc(ctx, i.continueAt, -1) + if i.err != nil { + i.items = nil + return item, i.err + } + + if len(i.items) == 0 { + i.err = io.EOF + return item, i.err + } + + // If the continueAt field is empty, it means we have reached the end of the list. + if i.continueAt == "" { + i.err = io.EOF + } + } + + // Retrieve and return the next item from the fetched batch. + item = i.items[0] + i.items = i.items[1:] + + return item, nil +} + +func parseLegacyListing(body io.Reader, n int) ([]string, string, error) { + type Response struct { + Name string `json:"name"` + Error string `json:"error"` + } + + scanner := bufio.NewScanner(body) + var ( + names []string + count int + ) + for scanner.Scan() { + var r Response + if err := json.Unmarshal(scanner.Bytes(), &r); err != nil { + return nil, "", err + } + if r.Error != "" { + return nil, "", errors.New(r.Error) + } + + names = append(names, r.Name) + count++ + if count == n { + break + } + } + sort.Strings(names) + return names, "", nil +} + +func parseLegacyIdentityListing(body io.Reader, n int) ([]Identity, string, error) { + type Response struct { + Identity Identity `json:"identity"` + Error string `json:"error"` + } + + scanner := bufio.NewScanner(body) + var ( + names []Identity + count int + ) + for scanner.Scan() { + var r Response + if err := json.Unmarshal(scanner.Bytes(), &r); err != nil { + return nil, "", err + } + if r.Error != "" { + return nil, "", errors.New(r.Error) + } + + names = append(names, r.Identity) + count++ + if count == n { + break + } + } + return names, "", nil +} diff --git a/key.go b/key.go index a073ca0..9daa81c 100644 --- a/key.go +++ b/key.go @@ -7,7 +7,6 @@ package kes import ( "encoding/json" "errors" - "io" "strconv" "time" ) @@ -50,9 +49,8 @@ type PCP struct { // All valid cryptographic algorithms that can be used with keys. const ( - KeyAlgorithmUndefined KeyAlgorithm = iota - AES256_GCM_SHA256 - XCHACHA20_POLY1305 + AES256 = iota + ChaCha20 ) // KeyAlgorithm is an enum representing the algorithm @@ -62,14 +60,12 @@ type KeyAlgorithm uint // String returns the KeyAlgorithm's string representation. func (a KeyAlgorithm) String() string { switch a { - case KeyAlgorithmUndefined: - return "undefined" - case AES256_GCM_SHA256: - return "AES256-GCM_SHA256" - case XCHACHA20_POLY1305: - return "XCHACHA20-POLY1305" + case AES256: + return "AES256" + case ChaCha20: + return "ChaCha20" default: - return "invalid algorithm '" + strconv.Itoa(int(a)) + "'" + return "%!" + strconv.Itoa(int(a)) } } @@ -79,36 +75,26 @@ func (a KeyAlgorithm) String() string { // isn't valid. func (a KeyAlgorithm) MarshalText() ([]byte, error) { switch a { - case KeyAlgorithmUndefined: - return []byte{}, nil - case AES256_GCM_SHA256: - return []byte("AES256-GCM_SHA256"), nil - case XCHACHA20_POLY1305: - return []byte("XCHACHA20-POLY1305"), nil + case AES256: + return []byte("AES256"), nil + case ChaCha20: + return []byte("ChaCha20"), nil default: - return nil, errors.New("key: invalid algorithm '" + strconv.Itoa(int(a)) + "'") + return nil, errors.New("kes: invalid key algorithm '" + strconv.Itoa(int(a)) + "'") } } // UnmarshalText parses text as KeyAlgorithm text representation. func (a *KeyAlgorithm) UnmarshalText(text []byte) error { - if len(text) == 0 { - *a = KeyAlgorithmUndefined - return nil - } - switch s := string(text); s { - case "undefined": - *a = KeyAlgorithmUndefined - return nil - case "AES256-GCM_SHA256": - *a = AES256_GCM_SHA256 + case "AES256": + *a = AES256 return nil - case "XCHACHA20-POLY1305": - *a = XCHACHA20_POLY1305 + case "ChaCha20": + *a = ChaCha20 return nil default: - return errors.New("key: invalid algorithm '" + s + "'") + return errors.New("kes: invalid key algorithm '" + s + "'") } } @@ -160,184 +146,3 @@ func (k *KeyInfo) UnmarshalJSON(text []byte) error { k.CreatedBy = v.CreatedBy return nil } - -// KeyIterator iterates over a stream of KeyInfo objects. -// Close the KeyIterator to release associated resources. -type KeyIterator struct { - decoder *json.Decoder - closer io.Closer - - current KeyInfo - err error - closed bool -} - -// Value returns the current KeyInfo. It returns -// the same KeyInfo until Next is called again. -// -// If KeyIterator has been closed or if Next has not been -// called once resp. once Next returns false then the -// behavior of Value is undefined. -func (i *KeyIterator) Value() KeyInfo { return i.current } - -// Name returns the name of the current key. It is a -// short-hand for Value().Name. -func (i *KeyIterator) Name() string { return i.current.Name } - -// ID returns the ID of the current key. It is a -// short-hand for Value().ID. -func (i *KeyIterator) ID() string { return i.current.ID } - -// Algorithm returns the KeyAlgorithm of the current key. It is a -// short-hand for Value().Algorithm. -func (i *KeyIterator) Algorithm() KeyAlgorithm { return i.current.Algorithm } - -// CreatedAt returns the created-at timestamp of the current -// key. It is a short-hand for Value().CreatedAt. -func (i *KeyIterator) CreatedAt() time.Time { return i.current.CreatedAt } - -// CreatedBy returns the identiy that created the current key. -// It is a short-hand for Value().CreatedBy. -func (i *KeyIterator) CreatedBy() Identity { return i.current.CreatedBy } - -// Next returns true if there is another KeyInfo. -// It returns false if there are no more KeyInfo -// objects or when the KeyIterator encounters an -// error. -func (i *KeyIterator) Next() bool { - type Response struct { - Name string `json:"name"` - ID string `json:"id"` - Algorithm KeyAlgorithm `json:"algorithm"` - CreatedAt time.Time `json:"created_at"` - CreatedBy Identity `json:"created_by"` - - Err string `json:"error"` - } - if i.closed || i.err != nil { - return false - } - var resp Response - if err := i.decoder.Decode(&resp); err != nil { - if errors.Is(err, io.EOF) { - i.err = i.Close() - } else { - i.err = err - } - return false - } - if resp.Err != "" { - i.err = errors.New(resp.Err) - return false - } - i.current = KeyInfo{ - Name: resp.Name, - ID: resp.ID, - Algorithm: resp.Algorithm, - CreatedAt: resp.CreatedAt, - CreatedBy: resp.CreatedBy, - } - return true -} - -// WriteTo encodes and writes all remaining KeyInfos -// from its current iterator position to w. It returns -// the number of bytes written to w and the first error -// encounterred, if any. -func (i *KeyIterator) WriteTo(w io.Writer) (int64, error) { - type Response struct { - Name string `json:"name"` - ID string `json:"id"` - Algorithm KeyAlgorithm `json:"algorithm"` - CreatedAt time.Time `json:"created_at"` - CreatedBy Identity `json:"created_by"` - - Err string `json:"error"` - } - if i.err != nil { - return 0, i.err - } - if i.closed { - return 0, errors.New("kes: WriteTo called after Close") - } - - cw := countWriter{W: w} - encoder := json.NewEncoder(&cw) - for { - var resp Response - if err := i.decoder.Decode(&resp); err != nil { - if errors.Is(err, io.EOF) { - i.err = i.Close() - } else { - i.err = err - } - return cw.N, i.err - } - if resp.Err != "" { - i.err = errors.New(resp.Err) - return cw.N, i.err - } - info := KeyInfo{ - Name: resp.Name, - ID: resp.ID, - Algorithm: resp.Algorithm, - CreatedAt: resp.CreatedAt, - CreatedBy: resp.CreatedBy, - } - if err := encoder.Encode(info); err != nil { - i.err = err - return cw.N, err - } - } -} - -// Values returns up to the next n KeyInfo values. Subsequent -// calls will yield further PolicyInfos if there are any. -// -// If n > 0, Values returns at most n KeyInfo structs. In this case, -// if Values returns an empty slice, it will return an error explaining -// why. At the end of the listing, the error is io.EOF. -// -// If n <= 0, Values returns all remaining KeyInfo records. In this -// case, Values always closes the KeyIterator. When it succeeds, it -// returns a nil error, not io.EOF. -func (i *KeyIterator) Values(n int) ([]KeyInfo, error) { - values := []KeyInfo{} - if n > 0 && i.closed { - return values, io.EOF // Return early, don't alloc a slice - } - if n > 0 { - values = make([]KeyInfo, 0, n) - } - - var count int - for i.Next() { - values = append(values, i.Value()) - count++ - - if n > 0 && count >= n { - return values, nil - } - } - if err := i.Close(); err != nil { - return values, err - } - if n > 0 && len(values) == 0 { // As by doc contract - return values, io.EOF - } - return values, nil -} - -// Close closes the KeyIterator and releases -// any associated resources. -func (i *KeyIterator) Close() error { - if !i.closed { - err := i.closer.Close() - if i.err == nil { - i.err = err - } - i.closed = true - return err - } - return i.err -} diff --git a/key_test.go b/key_test.go index 4d1c7e1..d00c972 100644 --- a/key_test.go +++ b/key_test.go @@ -21,10 +21,9 @@ var keyAlgorithmStringTests = []struct { Algorithm KeyAlgorithm String string }{ - {Algorithm: KeyAlgorithmUndefined, String: "undefined"}, - {Algorithm: AES256_GCM_SHA256, String: "AES256-GCM_SHA256"}, - {Algorithm: XCHACHA20_POLY1305, String: "XCHACHA20-POLY1305"}, - {Algorithm: XCHACHA20_POLY1305 + 1, String: "invalid algorithm '" + strconv.Itoa(int(XCHACHA20_POLY1305+1)) + "'"}, + {Algorithm: AES256, String: "AES256"}, + {Algorithm: ChaCha20, String: "ChaCha20"}, + {Algorithm: ChaCha20 + 1, String: "%!" + strconv.Itoa(int(ChaCha20+1))}, } func TestKeyAlgorithm_MarshalText(t *testing.T) { @@ -49,10 +48,9 @@ var keyAlgorithmMarshalTextTests = []struct { String string ShouldFail bool }{ - {Algorithm: KeyAlgorithmUndefined, String: ""}, - {Algorithm: AES256_GCM_SHA256, String: "AES256-GCM_SHA256"}, - {Algorithm: XCHACHA20_POLY1305, String: "XCHACHA20-POLY1305"}, - {Algorithm: XCHACHA20_POLY1305 + 1, ShouldFail: true}, + {Algorithm: AES256, String: "AES256"}, + {Algorithm: ChaCha20, String: "ChaCha20"}, + {Algorithm: ChaCha20 + 1, ShouldFail: true}, } func TestKeyAlgorithm_UnmarshalText(t *testing.T) { @@ -78,10 +76,8 @@ var keyAlgorithmUnmarshalTextTests = []struct { Algorithm KeyAlgorithm ShouldFail bool }{ - {String: "", Algorithm: KeyAlgorithmUndefined}, - {String: "undefined", Algorithm: KeyAlgorithmUndefined}, - {String: "AES256-GCM_SHA256", Algorithm: AES256_GCM_SHA256}, - {String: "XCHACHA20-POLY1305", Algorithm: XCHACHA20_POLY1305}, + {String: "AES256", Algorithm: AES256}, + {String: "ChaCha20", Algorithm: ChaCha20}, - {String: "AES256-GCM-SHA256", Algorithm: AES256_GCM_SHA256, ShouldFail: true}, + {String: "AES256-GCM-SHA256", ShouldFail: true}, } diff --git a/policy.go b/policy.go index d8b7b23..c12f2fc 100644 --- a/policy.go +++ b/policy.go @@ -4,206 +4,55 @@ package kes -import ( - "encoding/json" - "errors" - "io" - "time" -) +import "time" -// Policy contains a set of rules that explicitly allow -// or deny HTTP requests. +// A Policy represents a set of rules that determine whether an HTTP request +// is accepted or rejected. It consists of two sets of rules: +// allow rules and deny rules. // -// These rules are specified as glob patterns. The rule -// applies if the pattern matches the request URL path. -// For more details on the glob syntax in general see [1] -// and for the specific pattern syntax see [2]. +// If any rule from the deny set matches an incoming HTTP request, the request +// is rejected. Conversely, if any rule from the allow set matches, the request +// is accepted. If no rule matches, the request is also rejected. +// Therefore, an empty Policy, without any rules, rejects any request. // -// A policy contains two different rule sets: -// - Allow rules -// - Deny rules +// A rule set is defined by a collection of API path patterns. +// An API path pattern consists of the KES server API path and an +// optional resource pattern. For example, "/v1/key/describe/my-key*" +// consists of the "/v1/key/describe" API path and the resource +// pattern "my-key*". // -// A policy determines whether a request should be allowed -// or denied in two steps. First, it iterates over all deny -// rules. If any deny rules matches the given request then -// the request is rejected. Then it iterates over all -// allow rules. If any allow rule matches the given request -// then the request is accepted. Otherwise, the request -// is rejected by default. -// Hence, a request is only accepted if at least one allow -// rules and no deny rule matches the request. Also, a deny -// rule takes precedence over an allow rule. +// When matching API path patterns: +// - If the resource pattern does not end with an asterisk ('*') character, +// the API path pattern only matches requests with an URL path equal to the pattern. +// - If the resource pattern ends with an asterisk ('*') character, +// the API path pattern matches if the API path pattern (without the asterisk) is a prefix of the URL path. // -// [1]: https://en.wikipedia.org/wiki/Glob_(programming) -// [2]: https://golang.org/pkg/path/#Match +// An API path pattern cannot contain more than one asterisk character. +// API path patterns can be viewed as a subset of glob patterns. +// +// Here's an example defining a policy: +// +// policy := Policy{ +// Allow: map[string]kes.Rule{ +// "/v1/status": {}, +// "/v1/key/describe/my-key*": {}, +// "/v1/key/generate/my-key*": {}, +// "/v1/key/decrypt/my-key*": {}, +// }, +// } type Policy struct { - Allow []string // Set of allow patterns - Deny []string // Set of deny patterns + Allow map[string]Rule // Set of allow rules + Deny map[string]Rule // Set of deny rules Info PolicyInfo // Info contains metadata for the Policy. } +// A Rule controls HTTP requests and are part of a policy. +type Rule struct{} + // PolicyInfo describes a KES policy. type PolicyInfo struct { Name string `json:"name"` // Name of the policy CreatedAt time.Time `json:"created_at,omitempty"` // Point in time when the policy was created CreatedBy Identity `json:"created_by,omitempty"` // Identity that created the policy } - -// PolicyIterator iterates over a stream of PolicyInfo objects. -// Close the PolicyIterator to release associated resources. -type PolicyIterator struct { - decoder *json.Decoder - closer io.Closer - - current PolicyInfo - err error - closed bool -} - -// Value returns the current PolicyInfo. It remains valid -// until Next is called again. -func (i *PolicyIterator) Value() PolicyInfo { return i.current } - -// Name returns the name of the current policy. -// It is a short-hand for Value().Name. -func (i *PolicyIterator) Name() string { return i.current.Name } - -// CreatedAt returns the created at timestamp of the current -// policy. It is a short-hand for Value().CreatedAt. -func (i *PolicyIterator) CreatedAt() time.Time { return i.current.CreatedAt } - -// CreatedBy returns the identiy that created the current policy. -// It is a short-hand for Value().CreatedBy. -func (i *PolicyIterator) CreatedBy() Identity { return i.current.CreatedBy } - -// Next returns true if there is another PolicyInfo. -// It returns false if there are no more PolicyInfo -// objects or when the PolicyIterator encounters an -// error. -func (i *PolicyIterator) Next() bool { - type Response struct { - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` - CreatedBy Identity `json:"created_by"` - - Err string `json:"error"` - } - if i.closed || i.err != nil { - return false - } - - var resp Response - if err := i.decoder.Decode(&resp); err != nil { - if errors.Is(err, io.EOF) { - i.err = i.Close() - } else { - i.err = err - } - return false - } - if resp.Err != "" { - i.err = errors.New(resp.Err) - return false - } - - i.current = PolicyInfo{ - Name: resp.Name, - CreatedAt: resp.CreatedAt, - CreatedBy: resp.CreatedBy, - } - return true -} - -// WriteTo encodes and writes all remaining PolicyInfos -// from its current iterator position to w. It returns -// the number of bytes written to w and the first error -// encounterred, if any. -func (i *PolicyIterator) WriteTo(w io.Writer) (int64, error) { - type Response struct { - Name string `json:"name"` - CreatedAt time.Time `json:"created_at,omitempty"` - CreatedBy Identity `json:"created_by,omitempty"` - - Err string `json:"error,omitempty"` - } - if i.err != nil { - return 0, i.err - } - if i.closed { - return 0, errors.New("kes: WriteTo called after Close") - } - - cw := countWriter{W: w} - encoder := json.NewEncoder(&cw) - for { - var resp Response - if err := i.decoder.Decode(&resp); err != nil { - if errors.Is(err, io.EOF) { - i.err = i.Close() - } else { - i.err = err - } - return cw.N, i.err - } - if resp.Err != "" { - i.err = errors.New(resp.Err) - return cw.N, i.err - } - if err := encoder.Encode(resp); err != nil { - i.err = err - return cw.N, err - } - } -} - -// Values returns up to the next n PolicyInfo values. Subsequent -// calls will yield further PolicyInfos if there are any. -// -// If n > 0, Values returns at most n PolicyInfo structs. In this case, -// if Values returns an empty slice, it will return an error explaining -// why. At the end of the listing, the error is io.EOF. -// -// If n <= 0, Values returns all remaining PolicyInfo records. In this -// case, Values always closes the PolicyIterator. When it succeeds, it -// returns a nil error, not io.EOF. -func (i *PolicyIterator) Values(n int) ([]PolicyInfo, error) { - values := []PolicyInfo{} - if n > 0 && i.closed { - return values, io.EOF // Return early, don't alloc a slice - } - if n > 0 { - values = make([]PolicyInfo, 0, n) - } - - var count int - for i.Next() { - values = append(values, i.Value()) - count++ - - if n > 0 && count >= n { - return values, nil - } - } - if err := i.Close(); err != nil { - return values, err - } - if n > 0 && len(values) == 0 { // As by doc contract - return values, io.EOF - } - return values, nil -} - -// Close closes the PolicyIterator and releases -// any associated resources. -func (i *PolicyIterator) Close() error { - if !i.closed { - err := i.closer.Close() - if i.err == nil { - i.err = err - } - i.closed = true - return err - } - return i.err -} diff --git a/retry.go b/retry.go index 5c05016..548572f 100644 --- a/retry.go +++ b/retry.go @@ -70,6 +70,7 @@ func withHeader(key, value string) requestOption { type loadBalancer struct { lock sync.Mutex endpoints map[string]time.Time + enclave string } // Send creates a new HTTP request with the given method, context @@ -91,6 +92,9 @@ func (lb *loadBalancer) Send(ctx context.Context, client *retry, method string, if err != nil { return nil, err } + if lb.enclave != "" { + request.Header.Set("Kes-Enclave", lb.enclave) + } for _, opt := range options { opt(request) } diff --git a/secret.go b/secret.go index aedcf4f..bb72f3e 100644 --- a/secret.go +++ b/secret.go @@ -34,7 +34,7 @@ func (s SecretType) String() string { case SecretGeneric: return "generic" default: - return "%" + strconv.Itoa(int(s)) + return "%!" + strconv.Itoa(int(s)) } } @@ -46,7 +46,7 @@ func (s SecretType) MarshalText() ([]byte, error) { case SecretGeneric: return []byte("generic"), nil default: - return nil, errors.New("kes: invalid secret type '%" + strconv.Itoa(int(s)) + "'") + return nil, errors.New("kes: invalid secret type '" + strconv.Itoa(int(s)) + "'") } } diff --git a/secret_test.go b/secret_test.go index 5a357af..9293943 100644 --- a/secret_test.go +++ b/secret_test.go @@ -24,7 +24,7 @@ var secretTypeStringTests = []struct { {Type: 0, String: "generic"}, // 0 {Type: SecretGeneric, String: "generic"}, // 1 - {Type: 1, String: "%1"}, // 2 - invalid type + {Type: 1, String: "%!1"}, // 2 - invalid type } func TestSecretType_MarshalText(t *testing.T) {