Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core: add jwks rpc and http api #18035

Merged
merged 6 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .semgrep/rpc_endpoint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ rules:
- pattern-not: '"Status.Leader"'
- pattern-not: '"Status.Peers"'
- pattern-not: '"Status.Version"'
- pattern-not: '"Keyring.ListPublic"'
message: "RPC method $METHOD appears to be unauthenticated"
languages:
- "go"
Expand Down
3 changes: 3 additions & 0 deletions command/agent/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.Handle("/v1/vars", wrapCORS(s.wrap(s.VariablesListRequest)))
s.mux.Handle("/v1/var/", wrapCORSWithAllowedMethods(s.wrap(s.VariableSpecificRequest), "HEAD", "GET", "PUT", "DELETE"))

// JWKS Handler
s.mux.HandleFunc("/.well-known/jwks.json", s.wrap(s.JWKSRequest))
Comment on lines +504 to +505
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically the HTTP server will be protected with mTLS, which won't be reachable by third parties. So this probably needs to get served on a separate HTTP listener. (On its own port, I suppose?) This PR is already a nice size so we don't have to solve that in this PR, but just wanted to make sure we didn't forget about that problem.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah in the RFC (internal only, sorry folks) I call this out as a problem.

The Task API (+Workload Identity) gets around the mTLS problem for things running in Nomad... which our friends Consul and Vault usually are not.

...so then you're left with the option of running an mTLS terminating proxy just for this endpoint which is a hassle to say the least. How the proxy and the JWT's issuer field interacted could be problematic as well, although I don't think for Consul and Vault because their JWT auth methods allow you to specify the JWKSURL explicitly and only seem to use the issuer claim for mapping.

That being said this is only a problem for folks running with tls.verify_https_client = true explicitly as it defaults to false even with mTLS enabled. I think this is still an ok default and recommendation as the HTTP endpoints will still be protected by (asymmetric) TLS, and the Consul and Vault JWT auth methods support setting a custom CA cert for that.

.......even after all of that we should probably add a "Only TLS, Not mTLS" HTTP listener for this, the eventual OIDC discovery endpoint, and maybe other "low risk" endpoints like metrics.

While I feel like the situation has fairly reasonable options, I think it's clearly too complicated. Somebody hardening their cluster by setting tls.verify_https_client = true one day breaking their Consul integration due to the JWT auth method lacking a client certificate would just be hellish to debug. Definitely a : (╯°□°)╯︵ ┻━┻ situation I want to avoid.

Copy link
Member

@tgross tgross Jul 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I keep forgetting about tls.verify_https_client = false being the default. Even Vault's equivalent is set to false.

We definitely don't need to solve this in this PR for sure 😀


agentConfig := s.agent.GetConfig()
uiConfigEnabled := agentConfig.UI != nil && agentConfig.UI.Enabled

Expand Down
67 changes: 67 additions & 0 deletions command/agent/keyring_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,79 @@
package agent

import (
"crypto/ed25519"
"fmt"
"net/http"
"strings"
"time"

"github.com/go-jose/go-jose/v3"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/nomad/structs"
)

// jwksMinMaxAge is the minimum amount of time the JWKS endpoint will instruct
// consumers to cache a response for.
const jwksMinMaxAge = 15 * time.Minute

// JWKSRequest is used to handle JWKS requests. JWKS stands for JSON Web Key
// Sets and returns the public keys used for signing workload identities. Third
// parties may use this endpoint to validate workload identities. Consumers
// should cache this endpoint, preferably until an unknown kid is encountered.
func (s *HTTPServer) JWKSRequest(resp http.ResponseWriter, req *http.Request) (any, error) {
if req.Method != http.MethodGet {
return nil, CodedError(405, ErrInvalidMethod)
}

args := structs.GenericRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}

var rpcReply structs.KeyringListPublicResponse
if err := s.agent.RPC("Keyring.ListPublic", &args, &rpcReply); err != nil {
return nil, err
}
setMeta(resp, &rpcReply.QueryMeta)

// Key set will change after max(CreateTime) + RotationThreshold.
var newestKey int64
jwks := make([]jose.JSONWebKey, 0, len(rpcReply.PublicKeys))
for _, pubKey := range rpcReply.PublicKeys {
if pubKey.CreateTime > newestKey {
newestKey = pubKey.CreateTime
}

jwk := jose.JSONWebKey{
KeyID: pubKey.KeyID,
Algorithm: pubKey.Algorithm,
Use: pubKey.Use,
}
switch alg := pubKey.Algorithm; alg {
case structs.PubKeyAlgEdDSA:
// Convert public key bytes to an ed25519 public key
jwk.Key = ed25519.PublicKey(pubKey.PublicKey)
default:
s.logger.Warn("unknown public key algorithm. server is likely newer than client", "alg", alg)
}
Comment on lines +55 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this error message is suggesting that the request came to a Nomad client and was forwarded to a server that was newer, but the text isn't super clear and it's not necessarily the case (ex. the request was sent to a Nomad server that hadn't been upgraded and then that was forwarded to the leader which was).

What if the RPC returned both the []byte and the string representation needed by downstream consumers, so that we didn't have to worry about this conversion at all? I know this isn't how we'd normally handle this kind of problem, but it seems like it'd cut out a lot of upgrade-related worries in the future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I struggled with what responsibilities to put in the RPC vs HTTP handlers. If I understand your proposal it would mean the KeyringPublicKey struct the RPC handler returns would get changed to something like:

type KeyringPublicKey struct {
	KeyID     string `json:"kid"`

	// Always "OKP" (octet key pair) currently
	KeyType     string `json:"kty"`

	PublicKey []byte `json:"x"`

	// Always "EdDSA" currently
	Algorithm string `json:"crv"`

	// Always "Ed25519" currently, must match alg
	Curve string `json:"alg"`

	// Always "sig" currently
	Use string `json:"use"`

	// Purely for Nomad's cache control use. Not part of JWK spec.
	CreateTime int64 `json:"-"`
}

Then we would just JSON marshal struct { Keys []KeyringPublicKey } in the HTTP handler and not even touch go-jose.JSONWebKey.

Reason 1 Against: Too DIY

I avoided this initially because it felt too DIY. The nice thing about go-jose.JSONWebKey is that it knows how to convert key material to the appropriate algorithm (agl), curve (crv), and type (kty) fields. In the future if we add other key types, as long as we updated the mapping of Algorithm -> Go Type, go-jose would ensure it was marshalled properly.

That being said go-jose is "saving" us 2 fields currently (kty and crv)... and crv we're effectively setting ourselves by doing the type conversion (that as you know is easy to forget!).

So my "too DIY to skip go-jose" argument seems weak at best...

Reason 2 Against: Validating JWTs in the Agent

But there's another reason to do the conversion here: if we ever want to validate JWTs in the Agent, the Agent needs to do the conversion we're doing here. There's no way to avoid converting Key []byte -> ed25519.PublicKey at some point in order to use it.

The auth middleware used for the Task API and the WhoAmI RPC would both benefit from being able to validate JWTs using these PublicKeys. In my other branch I have a Keyring.ListPublic -> PublicKeyCache conversion implemented so the JWKS endpoint and auth middleware could share the underlying public keys in their native Go types.

I think this is reason enough to keep go-jose.JSONWebKey around even though the type conversions are gross and error prone? I think we can keep the problematic conversion to 1 or 2 places in code.

Alternative: JSONWebKeySet internally

Plan: just have the RPC return go-jose.JSONWebKeySet and just let the HTTP handler serialize it.

Pro: All of the key handling is the encrypter and keyring_endpoint files.

Con:

I don't think it's safe to send go-jose's JSONWebKey struct over our msgpack RPC. Maybe it is, and I could test it, but even if it is today I'm worried a change in either go-jose, msgpack, or even Go's crypto library could break things.

My main concern is JSONWebKey.Key any type confusion: go-jose relies on the key's concrete type to properly emit the right JWKS JSON (through a custom marshal implementation and internal struct). If the RPC/msgpack layer were to lose the concrete type information, go-jose may guess that it's a symmetric encryption key and cause a really confusing error (that I caused the other day and you helped me debug!).

I'm also a little worried that if we started using X509 certificates in the future that the x509.Certificate might not roundtrip cleanly through msgpack, so we'd silently change things like timestamps?

I don't know... these may be unreasonable concerns, but explicit is better than implicit seems doubly true to me when it comes to cryptographic materials and Key interface{} is the definition of implicit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand your proposal it would mean the KeyringPublicKey struct the RPC handler returns would get changed to something like:
...

Yeah that's pretty much what I was thinking. To be clear, I'm less concerned about where in the code the conversion is happening than avoiding weird cross-version problems in doing that conversion with info we got from the server. But...

The auth middleware used for the Task API and the WhoAmI RPC would both benefit from being able to validate JWTs using these PublicKeys.

I think I'm probably sold on the basis of this alone. We know we're likely to want to do this in the near-ish future.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about my comment about the text of the error message though?


jwks = append(jwks, jwk)
}

// Have nonzero create times and threshold so set a reasonable cache time.
if newestKey > 0 && rpcReply.RotationThreshold > 0 {
exp := time.Unix(0, newestKey).Add(rpcReply.RotationThreshold)
maxAge := helper.ExpiryToRenewTime(exp, time.Now, jwksMinMaxAge)
resp.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", int(maxAge.Seconds())))
}

out := &jose.JSONWebKeySet{
Keys: jwks,
}

return out, nil
}

// KeyringRequest is used route operator/raft API requests to the implementing
// functions.
func (s *HTTPServer) KeyringRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ require (
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/fatih/color v1.15.0
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/go-jose/go-jose/v3 v3.0.0
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,8 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
Expand Down Expand Up @@ -1464,6 +1466,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
Expand Down
27 changes: 27 additions & 0 deletions helper/retry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package helper

import (
"time"
)

// ExpiryToRenewTime calculates how long until clients should try to renew
// credentials based on their expiration time and now.
//
// Renewals will begin halfway between now and the expiry plus some jitter.
//
// If the expiration is in the past or less than the min wait, then the min
// wait time will be used with jitter.
Comment on lines +15 to +16
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But doesn't this behavior floor the result at 15min? If so, when would the client ever renew if the time to renew is always 15min in the future? Is the assumption that clients will call this function (or something that calls it, like the JWKS endpoint) once and then set a timer on that value?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the assumption that clients will call this function (or something that calls it, like the JWKS endpoint) once and then set a timer on that value?

Yes, but oof you're right this function has a sort of Fence Post Problem: if you call it before each fetch to determine whether it's time to fetch: you'll never renew!

However if you call it after you fetch it will give you a reasonable time to fetch in. JWKS is the latter, so I think it's safe:

  1. Consul fetches JWKS v1 response, Cache-Control=15 minutes
  2. Consul uses JWKS v1 for 15 minutes
  3. Consul fetches JWKS v2 response, Cache-Control=...

If Consul was using something like ExpiryToRenewTime prior to each fetch I think we'd have the problem you mention.

I'll see if there's a safer way to write this function when I post the next PR that references it. It's possible we don't want to actually share this code at all and can just make it a private function for JWKS to use.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also just realized I named it "...to renew time" when it returns a duration which is a it silly)

func ExpiryToRenewTime(exp time.Time, now func() time.Time, minWait time.Duration) time.Duration {
left := exp.Sub(now())

renewAt := left / 2

if renewAt < minWait {
return minWait + RandomStagger(minWait/10)
}

return renewAt + RandomStagger(renewAt/10)
}
76 changes: 76 additions & 0 deletions helper/retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package helper

import (
"testing"
"time"

"github.com/shoenig/test/must"
)

// TestExpiryToRenewTime_0Min asserts that ExpiryToRenewTime with a 0 min wait
// will cause an immediate renewal
func TestExpiryToRenewTime_0Min(t *testing.T) {
exp := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
now := func() time.Time {
return time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC)
}

renew := ExpiryToRenewTime(exp, now, 0)

must.Zero(t, renew)
}

// TestExpiryToRenewTime_14Days asserts that ExpiryToRenewTime begins trying to
// renew at or after 7 days of a 14 day expiration window.
func TestExpiryToRenewTime_30Days(t *testing.T) {
exp := time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC)
now := func() time.Time {
return time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
}
min := 20 * time.Minute

renew := ExpiryToRenewTime(exp, now, min)

// Renew should be much greater than min wait
must.Greater(t, min, renew)

// Renew should be >= 7 days
must.GreaterEq(t, 7*24*time.Hour, renew)
}

// TestExpiryToRenewTime_UnderMin asserts that ExpiryToRenewTime uses the min
// wait + jitter if it is greater than the time until expiry.
func TestExpiryToRenewTime_UnderMin(t *testing.T) {
exp := time.Date(2023, 1, 1, 0, 0, 10, 0, time.UTC)
now := func() time.Time {
return time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
}
min := 20 * time.Second

renew := ExpiryToRenewTime(exp, now, min)

// Renew should be >= min wait (jitter can be 0)
must.GreaterEq(t, min, renew)

// When we fallback to the min wait it means we miss the expiration, but this
// is necessary to prevent stampedes after outages and partitions.
must.GreaterEq(t, exp.Sub(now()), renew)
Comment on lines +58 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to have this comment in the docstring for ExpiryToRenewTime rather than the test, because the "why" wasn't clear looking at the code.

}

// TestExpiryToRenewTime_Expired asserts that ExpiryToRenewTime defaults to
// minWait (+jitter) if the renew time has already elapsed.
func TestExpiryToRenewTime_Expired(t *testing.T) {
exp := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
now := func() time.Time {
return time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)
}
min := time.Hour

renew := ExpiryToRenewTime(exp, now, min)

must.Greater(t, min, renew)
must.Less(t, min*2, renew)
}
20 changes: 20 additions & 0 deletions nomad/encrypter.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,26 @@ func (e *Encrypter) loadKeyFromStore(path string) (*structs.RootKey, error) {
}, nil
}

// GetPublicKey returns the public signing key for the requested key id or an
// error if the key could not be found.
func (e *Encrypter) GetPublicKey(keyID string) (*structs.KeyringPublicKey, error) {
e.lock.Lock()
defer e.lock.Unlock()

ks, err := e.keysetByIDLocked(keyID)
if err != nil {
return nil, err
}

return &structs.KeyringPublicKey{
KeyID: ks.rootKey.Meta.KeyID,
PublicKey: ks.privateKey.Public().(ed25519.PublicKey),
Algorithm: structs.PubKeyAlgEdDSA,
Use: structs.PubKeyUseSig,
CreateTime: ks.rootKey.Meta.CreateTime,
}, nil
}

// newKMSWrapper returns a go-kms-wrapping interface the caller can use to
// encrypt the RootKey with a key encryption key (KEK). This is a bit of
// security theatre for local on-disk key material, but gives us a shim for
Expand Down
63 changes: 63 additions & 0 deletions nomad/keyring_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,66 @@ func (k *Keyring) Delete(args *structs.KeyringDeleteRootKeyRequest, reply *struc
reply.Index = index
return nil
}

// ListPublic signing keys used for workload identities. This RPC is used to
// back a JWKS endpoint.
//
// Unauthenticated because public keys are not sensitive.
func (k *Keyring) ListPublic(args *structs.GenericRequest, reply *structs.KeyringListPublicResponse) error {

// JWKS is a public endpoint: intentionally ignore auth errors and only
// authenticate to measure rate metrics.
k.srv.Authenticate(k.ctx, args)
if done, err := k.srv.forward("Keyring.ListPublic", args, args, reply); done {
return err
}
schmichael marked this conversation as resolved.
Show resolved Hide resolved
k.srv.MeasureRPCRate("keyring", structs.RateMetricList, args)

defer metrics.MeasureSince([]string{"nomad", "keyring", "list_public"}, time.Now())

// Expose root_key_rotation_threshold so consumers can determine reasonable
// cache settings.
reply.RotationThreshold = k.srv.config.RootKeyRotationThreshold

// Setup the blocking query
opts := blockingOptions{
queryOpts: &args.QueryOptions,
queryMeta: &reply.QueryMeta,
run: func(ws memdb.WatchSet, s *state.StateStore) error {

// retrieve all the key metadata
snap, err := k.srv.fsm.State().Snapshot()
if err != nil {
return err
}
iter, err := snap.RootKeyMetas(ws)
if err != nil {
return err
}

pubKeys := []*structs.KeyringPublicKey{}
for {
raw := iter.Next()
if raw == nil {
break
}

keyMeta := raw.(*structs.RootKeyMeta)
if keyMeta.State == structs.RootKeyStateDeprecated {
// Only include valid keys
continue
}

pubKey, err := k.encrypter.GetPublicKey(keyMeta.KeyID)
if err != nil {
return err
}

pubKeys = append(pubKeys, pubKey)
}
reply.PublicKeys = pubKeys
return k.srv.replySetIndex(state.TableRootKeyMeta, &reply.QueryMeta)
},
}
return k.srv.blockingRPC(&opts)
}
57 changes: 57 additions & 0 deletions nomad/keyring_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/require"

"github.com/hashicorp/nomad/ci"
Expand Down Expand Up @@ -295,3 +296,59 @@ func TestKeyringEndpoint_Rotate(t *testing.T) {
gotKey := getResp.Key
require.Len(t, gotKey.Key, 32)
}

// TestKeyringEndpoint_ListPublic asserts the Keyring.ListPublic RPC returns
// all keys which may be in use for active crytpographic material (variables,
// valid JWTs).
func TestKeyringEndpoint_ListPublic(t *testing.T) {

ci.Parallel(t)
srv, rootToken, shutdown := TestACLServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
codec := rpcClient(t, srv)

// Assert 1 key exists and normal fields are set
req := structs.GenericRequest{
QueryOptions: structs.QueryOptions{
Region: "global",
AuthToken: "ignored!",
},
}
var resp structs.KeyringListPublicResponse
must.NoError(t, msgpackrpc.CallWithCodec(codec, "Keyring.ListPublic", &req, &resp))
must.Eq(t, srv.config.RootKeyRotationThreshold, resp.RotationThreshold)
must.Len(t, 1, resp.PublicKeys)
must.NonZero(t, resp.Index)

// Rotate keys and assert there are now 2 keys
rotateReq := &structs.KeyringRotateRootKeyRequest{
WriteRequest: structs.WriteRequest{
Region: "global",
AuthToken: rootToken.SecretID,
},
}
var rotateResp structs.KeyringRotateRootKeyResponse
must.NoError(t, msgpackrpc.CallWithCodec(codec, "Keyring.Rotate", rotateReq, &rotateResp))
must.NotEq(t, resp.Index, rotateResp.Index)

// Verify we have a new key and the old one is inactive
var resp2 structs.KeyringListPublicResponse
must.NoError(t, msgpackrpc.CallWithCodec(codec, "Keyring.ListPublic", &req, &resp2))
must.Eq(t, srv.config.RootKeyRotationThreshold, resp2.RotationThreshold)
must.Len(t, 2, resp2.PublicKeys)
must.NonZero(t, resp2.Index)

found := false
for _, pk := range resp2.PublicKeys {
if pk.KeyID == resp.PublicKeys[0].KeyID {
must.False(t, found, must.Sprint("found the original public key twice"))
found = true
must.Eq(t, resp.PublicKeys[0], pk)
break
}
}
must.True(t, found, must.Sprint("original public key missing after rotation"))
}