From 0b0aa3efe878bfa27719e4d1ea257dd690759a48 Mon Sep 17 00:00:00 2001 From: Tim Gross Date: Fri, 20 May 2022 12:16:21 -0400 Subject: [PATCH] keyring HTTP API (#13077) --- api/keyring.go | 94 +++++++++++++++++ api/keyring_test.go | 64 ++++++++++++ command/agent/http.go | 2 +- command/agent/keyring_endpoint.go | 135 +++++++++++++++++++++++++ command/agent/keyring_endpoint_test.go | 92 +++++++++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 api/keyring.go create mode 100644 api/keyring_test.go create mode 100644 command/agent/keyring_endpoint.go create mode 100644 command/agent/keyring_endpoint_test.go diff --git a/api/keyring.go b/api/keyring.go new file mode 100644 index 000000000..e00e13cc9 --- /dev/null +++ b/api/keyring.go @@ -0,0 +1,94 @@ +package api + +import ( + "fmt" + "net/url" + "time" +) + +// Keyring is used to access the Secure Variables keyring +type Keyring struct { + client *Client +} + +// Keyring returns a handle to the Keyring endpoint +func (c *Client) Keyring() *Keyring { + return &Keyring{client: c} +} + +// EncryptionAlgorithm chooses which algorithm is used for +// encrypting / decrypting entries with this key +type EncryptionAlgorithm string + +const ( + EncryptionAlgorithmXChaCha20 EncryptionAlgorithm = "xchacha20" + EncryptionAlgorithmAES256GCM EncryptionAlgorithm = "aes256-gcm" +) + +// RootKey wraps key metadata and the key itself. The key must be +// base64 encoded +type RootKey struct { + Meta *RootKeyMeta + Key string +} + +// RootKeyMeta is the metadata used to refer to a RootKey. +type RootKeyMeta struct { + Active bool + KeyID string // UUID + Algorithm EncryptionAlgorithm + EncryptionsCount uint64 + CreateTime time.Time + CreateIndex uint64 + ModifyIndex uint64 +} + +// List lists all the keyring metadata +func (k *Keyring) List(q *QueryOptions) ([]*RootKeyMeta, *QueryMeta, error) { + var resp []*RootKeyMeta + qm, err := k.client.query("/v1/operator/keyring/keys", &resp, q) + if err != nil { + return nil, nil, err + } + return resp, qm, nil +} + +// Delete deletes a specific inactive key from the keyring +func (k *Keyring) Delete(opts *KeyringDeleteOptions, w *WriteOptions) (*WriteMeta, error) { + wm, err := k.client.delete(fmt.Sprintf("/v1/operator/keyring/key/%v", + url.PathEscape(opts.KeyID)), nil, nil, w) + return wm, err +} + +// KeyringDeleteOptions are parameters for the Delete API +type KeyringDeleteOptions struct { + KeyID string // UUID +} + +// Update upserts a key into the keyring +func (k *Keyring) Update(key *RootKey, w *WriteOptions) (*WriteMeta, error) { + wm, err := k.client.write("/v1/operator/keyring/keys", key, nil, w) + return wm, err +} + +// Rotate requests a key rotation +func (k *Keyring) Rotate(opts *KeyringRotateOptions, w *WriteOptions) (*RootKeyMeta, *WriteMeta, error) { + qp := url.Values{} + if opts != nil { + if opts.Algorithm != "" { + qp.Set("algo", string(opts.Algorithm)) + } + if opts.Full { + qp.Set("full", "true") + } + } + resp := &RootKeyMeta{} + wm, err := k.client.write("/v1/operator/keyring/rotate?"+qp.Encode(), nil, resp, w) + return resp, wm, err +} + +// KeyringRotateOptions are parameters for the Rotate API +type KeyringRotateOptions struct { + Full bool + Algorithm EncryptionAlgorithm +} diff --git a/api/keyring_test.go b/api/keyring_test.go new file mode 100644 index 000000000..a410fdfc1 --- /dev/null +++ b/api/keyring_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "encoding/base64" + "math/rand" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/nomad/api/internal/testutil" +) + +func TestKeyring_CRUD(t *testing.T) { + testutil.Parallel(t) + c, s := makeClient(t, nil, nil) + defer s.Stop() + + kr := c.Keyring() + + // Create a key by requesting a rotation + key, wm, err := kr.Rotate(nil, nil) + require.NoError(t, err) + require.NotNil(t, key) + assertWriteMeta(t, wm) + + // Read all the keys + keys, qm, err := kr.List(&QueryOptions{WaitIndex: key.CreateIndex}) + require.NoError(t, err) + assertQueryMeta(t, qm) + // TODO: there'll be 2 keys here once we get bootstrapping done + require.Len(t, keys, 1) + + // Write a new active key, forcing a rotation + id := "fd77c376-9785-4c80-8e62-4ec3ab5f8b9a" + buf := make([]byte, 128) + rand.Read(buf) + encodedKey := make([]byte, base64.StdEncoding.EncodedLen(128)) + base64.StdEncoding.Encode(encodedKey, buf) + + wm, err = kr.Update(&RootKey{ + Key: string(encodedKey), + Meta: &RootKeyMeta{ + KeyID: id, + Active: true, + Algorithm: EncryptionAlgorithmAES256GCM, + EncryptionsCount: 100, + }}, nil) + require.NoError(t, err) + assertWriteMeta(t, wm) + + // Delete the old key + wm, err = kr.Delete(&KeyringDeleteOptions{KeyID: keys[0].KeyID}, nil) + require.NoError(t, err) + assertWriteMeta(t, wm) + + // Read all the keys back + keys, qm, err = kr.List(&QueryOptions{WaitIndex: key.CreateIndex}) + require.NoError(t, err) + assertQueryMeta(t, qm) + // TODO: there'll be 2 keys here once we get bootstrapping done + require.Len(t, keys, 1) + require.Equal(t, id, keys[0].KeyID) + +} diff --git a/command/agent/http.go b/command/agent/http.go index 7b51883c0..2df2a6ac4 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -397,9 +397,9 @@ func (s HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/search/fuzzy", s.wrap(s.FuzzySearchRequest)) s.mux.HandleFunc("/v1/search", s.wrap(s.SearchRequest)) - s.mux.HandleFunc("/v1/operator/license", s.wrap(s.LicenseRequest)) s.mux.HandleFunc("/v1/operator/raft/", s.wrap(s.OperatorRequest)) + s.mux.HandleFunc("/v1/operator/keyring/", s.wrap(s.KeyringRequest)) s.mux.HandleFunc("/v1/operator/autopilot/configuration", s.wrap(s.OperatorAutopilotConfiguration)) s.mux.HandleFunc("/v1/operator/autopilot/health", s.wrap(s.OperatorServerHealth)) s.mux.HandleFunc("/v1/operator/snapshot", s.wrap(s.SnapshotRequest)) diff --git a/command/agent/keyring_endpoint.go b/command/agent/keyring_endpoint.go new file mode 100644 index 000000000..57ee00551 --- /dev/null +++ b/command/agent/keyring_endpoint.go @@ -0,0 +1,135 @@ +package agent + +import ( + "encoding/base64" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/nomad/structs" +) + +// KeyringRequest is used route operator/raft API requests to the implementing +// functions. +func (s *HTTPServer) KeyringRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + path := strings.TrimPrefix(req.URL.Path, "/v1/operator/keyring/") + switch { + case strings.HasPrefix(path, "keys"): + switch req.Method { + case http.MethodGet: + return s.keyringListRequest(resp, req) + case http.MethodPost, http.MethodPut: + return s.keyringUpsertRequest(resp, req) + default: + return nil, CodedError(405, ErrInvalidMethod) + } + case strings.HasPrefix(path, "key"): + keyID := strings.TrimPrefix(req.URL.Path, "/v1/operator/keyring/key/") + switch req.Method { + case http.MethodDelete: + return s.keyringDeleteRequest(resp, req, keyID) + default: + return nil, CodedError(405, ErrInvalidMethod) + } + case strings.HasPrefix(path, "rotate"): + return s.keyringRotateRequest(resp, req) + default: + return nil, CodedError(405, ErrInvalidMethod) + } +} + +func (s *HTTPServer) keyringListRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + args := structs.KeyringListRootKeyMetaRequest{} + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + var out structs.KeyringListRootKeyMetaResponse + if err := s.agent.RPC("Keyring.List", &args, &out); err != nil { + return nil, err + } + + setMeta(resp, &out.QueryMeta) + if out.Keys == nil { + out.Keys = make([]*structs.RootKeyMeta, 0) + } + return out.Keys, nil +} + +func (s *HTTPServer) keyringRotateRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + args := structs.KeyringRotateRootKeyRequest{} + s.parseWriteRequest(req, &args.WriteRequest) + + query := req.URL.Query() + switch query.Get("algo") { + case string(structs.EncryptionAlgorithmAES256GCM): + args.Algorithm = structs.EncryptionAlgorithmAES256GCM + case string(structs.EncryptionAlgorithmXChaCha20): + args.Algorithm = structs.EncryptionAlgorithmXChaCha20 + } + + if _, ok := query["full"]; ok { + args.Full = true + } + + var out structs.KeyringRotateRootKeyResponse + if err := s.agent.RPC("Keyring.Rotate", &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return out, nil +} + +func (s *HTTPServer) keyringUpsertRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + var key api.RootKey + if err := decodeBody(req, &key); err != nil { + return nil, CodedError(400, err.Error()) + } + if key.Meta == nil { + return nil, CodedError(400, "decoded key did not include metadata") + } + + decodedKey := make([]byte, base64.StdEncoding.DecodedLen(len(key.Key))) + _, err := base64.StdEncoding.Decode(decodedKey, []byte(key.Key)) + if err != nil { + return nil, CodedError(400, fmt.Sprintf("could not decode key: %v", err)) + } + + args := structs.KeyringUpdateRootKeyRequest{ + RootKey: &structs.RootKey{ + Key: decodedKey, + Meta: &structs.RootKeyMeta{ + Active: key.Meta.Active, + KeyID: key.Meta.KeyID, + Algorithm: structs.EncryptionAlgorithm(key.Meta.Algorithm), + EncryptionsCount: key.Meta.EncryptionsCount, + }, + }, + } + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.KeyringUpdateRootKeyResponse + if err := s.agent.RPC("Keyring.Update", &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return out, nil +} + +func (s *HTTPServer) keyringDeleteRequest(resp http.ResponseWriter, req *http.Request, keyID string) (interface{}, error) { + + args := structs.KeyringDeleteRootKeyRequest{KeyID: keyID} + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.KeyringDeleteRootKeyResponse + if err := s.agent.RPC("Keyring.Delete", &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + return out, nil +} diff --git a/command/agent/keyring_endpoint_test.go b/command/agent/keyring_endpoint_test.go new file mode 100644 index 000000000..0f592506b --- /dev/null +++ b/command/agent/keyring_endpoint_test.go @@ -0,0 +1,92 @@ +package agent + +import ( + "encoding/base64" + "math/rand" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/structs" +) + +func TestHTTP_Keyring_CRUD(t *testing.T) { + ci.Parallel(t) + + httpTest(t, nil, func(s *TestAgent) { + + respW := httptest.NewRecorder() + + // Rotate + + req, err := http.NewRequest(http.MethodPut, "/v1/operator/keyring/rotate", nil) + require.NoError(t, err) + obj, err := s.Server.KeyringRequest(respW, req) + require.NoError(t, err) + require.NotZero(t, respW.HeaderMap.Get("X-Nomad-Index")) + rotateResp := obj.(structs.KeyringRotateRootKeyResponse) + require.NotNil(t, rotateResp.Key) + require.True(t, rotateResp.Key.Active) + + // List + + req, err = http.NewRequest(http.MethodGet, "/v1/operator/keyring/keys", nil) + require.NoError(t, err) + obj, err = s.Server.KeyringRequest(respW, req) + require.NoError(t, err) + listResp := obj.([]*structs.RootKeyMeta) + require.Len(t, listResp, 1) + require.True(t, listResp[0].Active) + + // Update + + keyMeta := rotateResp.Key + keyBuf := make([]byte, 128) + rand.Read(keyBuf) + encodedKey := make([]byte, base64.StdEncoding.EncodedLen(128)) + base64.StdEncoding.Encode(encodedKey, keyBuf) + + newID := uuid.Generate() + + key := &api.RootKey{ + Meta: &api.RootKeyMeta{ + Active: true, + KeyID: newID, + Algorithm: api.EncryptionAlgorithm(keyMeta.Algorithm), + EncryptionsCount: 500, + }, + Key: string(encodedKey), + } + reqBuf := encodeReq(key) + + req, err = http.NewRequest(http.MethodPut, "/v1/operator/keyring/keys", reqBuf) + require.NoError(t, err) + obj, err = s.Server.KeyringRequest(respW, req) + require.NoError(t, err) + updateResp := obj.(structs.KeyringUpdateRootKeyResponse) + require.NotNil(t, updateResp) + + // Delete the old key and verify its gone + + id := rotateResp.Key.KeyID + req, err = http.NewRequest(http.MethodDelete, "/v1/operator/keyring/key/"+id, nil) + require.NoError(t, err) + obj, err = s.Server.KeyringRequest(respW, req) + require.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, "/v1/operator/keyring/keys", nil) + require.NoError(t, err) + obj, err = s.Server.KeyringRequest(respW, req) + require.NoError(t, err) + listResp = obj.([]*structs.RootKeyMeta) + require.Len(t, listResp, 1) + require.True(t, listResp[0].Active) + require.Equal(t, newID, listResp[0].KeyID) + + }) +}