From 36f56be61452b7382997a28ece5a1bda756023ab Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Fri, 2 Oct 2026 14:32:35 -0700 Subject: [PATCH 1/4] kubectl-ate: add commands to rotate actor JWT signing keys Rotating the actor JWT signing key meant editing the pool Secret by hand. ConcretePool gains AddAuthority, Activate, and RemoveAuthority, and kubectl ate admin gains add-jwt-key, activate-jwt-key, and remove-jwt-key on top of them, so a rotation is: add a key, which is published but signs nothing; activate it once relying parties have refetched the key set; remove the old key once its tokens expire. The pool refuses a duplicate or empty key ID, activating a key it does not hold, and removing the key that signs. Each command reads the Secret, changes the pool, and writes it back conditional on the resourceVersion it read, rerunning the change against the current pool on a conflict so a concurrent edit is not lost. The pool commands now share one helper to build the Kubernetes client, and make-jwt-pool's algorithm and key ID flag variables are renamed so the new commands can share them. --- cmd/kubectl-ate/README.md | 7 + cmd/kubectl-ate/internal/cmd/admin.go | 169 +++++++++++++++--- cmd/kubectl-ate/internal/cmd/admin_test.go | 156 ++++++++++++++++ .../localjwtauthority/localjwtauthority.go | 42 +++++ .../localjwtauthority_test.go | 95 ++++++++++ 5 files changed, 449 insertions(+), 20 deletions(-) diff --git a/cmd/kubectl-ate/README.md b/cmd/kubectl-ate/README.md index 55000782ec..03ec343604 100644 --- a/cmd/kubectl-ate/README.md +++ b/cmd/kubectl-ate/README.md @@ -319,4 +319,11 @@ kubectl ate admin make-ca-pool \ kubectl ate admin make-jwt-pool \ --name actor-id-jwt-pool \ --secret-namespace ate-system + +# Rotate the JWT signing key. The new key is published but signs nothing until +# activated; activate it once relying parties have refetched the key set, and +# remove the old key once every token it signed has expired. +kubectl ate admin add-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system +kubectl ate admin activate-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system --key-id +kubectl ate admin remove-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system --key-id ``` diff --git a/cmd/kubectl-ate/internal/cmd/admin.go b/cmd/kubectl-ate/internal/cmd/admin.go index 67d26831d8..a831176df9 100644 --- a/cmd/kubectl-ate/internal/cmd/admin.go +++ b/cmd/kubectl-ate/internal/cmd/admin.go @@ -15,6 +15,7 @@ package cmd import ( + "context" "fmt" "time" @@ -25,17 +26,19 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/util/retry" ) var ( - // Both pool commands write one secret, so they share the flags naming it. + // Each pool command writes one secret, so they share the flags naming it. poolSecretNamespaceFlag string poolSecretNameFlag string makeCaPoolIDFlag string makeCaPoolKeyTypeFlag string makeCaPoolValidityFlag time.Duration - makeJwtPoolAlgFlag string - makeJwtPoolKeyIDFlag string + jwtAlgFlag string + jwtKeyIDFlag string ) var adminCmd = &cobra.Command{ @@ -49,14 +52,9 @@ var makeCaPoolCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - kconfig, err := ateclient.LoadKubeConfig(kubeconfig, k8sContext) + kc, err := newKubeClient() if err != nil { - return fmt.Errorf("while reading kubeconfig: %w", err) - } - - kc, err := kubernetes.NewForConfig(kconfig) - if err != nil { - return fmt.Errorf("while creating Kubernetes client: %w", err) + return err } var keyType localca.KeyType @@ -125,31 +123,146 @@ var makeJwtPoolCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - kconfig, err := ateclient.LoadKubeConfig(kubeconfig, k8sContext) + kc, err := newKubeClient() if err != nil { - return fmt.Errorf("while reading kubeconfig: %w", err) + return err + } + + secret, keyID, err := newJWTPoolSecret(poolSecretNamespaceFlag, poolSecretNameFlag, jwtAlgFlag, jwtKeyIDFlag) + if err != nil { + return err } - kc, err := kubernetes.NewForConfig(kconfig) + _, err = kc.CoreV1().Secrets(poolSecretNamespaceFlag).Create(ctx, secret, metav1.CreateOptions{}) if err != nil { - return fmt.Errorf("while creating Kubernetes client: %w", err) + return fmt.Errorf("while uploading pool state to secret: %w", err) } - secret, keyID, err := newJWTPoolSecret(poolSecretNamespaceFlag, poolSecretNameFlag, makeJwtPoolAlgFlag, makeJwtPoolKeyIDFlag) + fmt.Printf("Successfully created JWT authority pool secret %s/%s with %s key %s\n", poolSecretNamespaceFlag, poolSecretNameFlag, jwtAlgFlag, keyID) + return nil + }, +} + +var addJwtKeyCmd = &cobra.Command{ + Use: "add-jwt-key", + Short: "Add an inactive signing key to a JWT authority pool secret", + Long: `Add an inactive signing key to a JWT authority pool secret. + +The key is published to relying parties but signs nothing until activate-jwt-key +makes it the signing key. Activate it only once relying parties have refetched +the key set.`, + RunE: func(cmd *cobra.Command, args []string) error { + kc, err := newKubeClient() if err != nil { return err } - _, err = kc.CoreV1().Secrets(poolSecretNamespaceFlag).Create(ctx, secret, metav1.CreateOptions{}) + authority, err := localjwtauthority.GenerateAuthority(jwtAlgFlag, jwtKeyIDFlag) if err != nil { - return fmt.Errorf("while uploading pool state to secret: %w", err) + return fmt.Errorf("while generating JWT authority: %w", err) + } + if err := updateJWTPool(cmd.Context(), kc.CoreV1().Secrets(poolSecretNamespaceFlag), poolSecretNameFlag, func(pool *localjwtauthority.ConcretePool) error { + return pool.AddAuthority(authority) + }); err != nil { + return err + } + + fmt.Printf("Added inactive %s key %s to JWT authority pool secret %s/%s\n", jwtAlgFlag, authority.ID, poolSecretNamespaceFlag, poolSecretNameFlag) + return nil + }, +} + +var activateJwtKeyCmd = &cobra.Command{ + Use: "activate-jwt-key", + Short: "Make a key in a JWT authority pool secret the one that signs", + RunE: func(cmd *cobra.Command, args []string) error { + kc, err := newKubeClient() + if err != nil { + return err } - fmt.Printf("Successfully created JWT authority pool secret %s/%s with %s key %s\n", poolSecretNamespaceFlag, poolSecretNameFlag, makeJwtPoolAlgFlag, keyID) + if err := updateJWTPool(cmd.Context(), kc.CoreV1().Secrets(poolSecretNamespaceFlag), poolSecretNameFlag, func(pool *localjwtauthority.ConcretePool) error { + return pool.Activate(jwtKeyIDFlag) + }); err != nil { + return err + } + + fmt.Printf("Activated key %s in JWT authority pool secret %s/%s\n", jwtKeyIDFlag, poolSecretNamespaceFlag, poolSecretNameFlag) return nil }, } +var removeJwtKeyCmd = &cobra.Command{ + Use: "remove-jwt-key", + Short: "Remove an inactive key from a JWT authority pool secret", + Long: `Remove an inactive key from a JWT authority pool secret. + +Tokens the key signed stop verifying once relying parties refetch the key set, +so remove it only after the last of them has expired.`, + RunE: func(cmd *cobra.Command, args []string) error { + kc, err := newKubeClient() + if err != nil { + return err + } + + if err := updateJWTPool(cmd.Context(), kc.CoreV1().Secrets(poolSecretNamespaceFlag), poolSecretNameFlag, func(pool *localjwtauthority.ConcretePool) error { + return pool.RemoveAuthority(jwtKeyIDFlag) + }); err != nil { + return err + } + + fmt.Printf("Removed key %s from JWT authority pool secret %s/%s\n", jwtKeyIDFlag, poolSecretNamespaceFlag, poolSecretNameFlag) + return nil + }, +} + +// updateJWTPool applies change to the pool in the named secret and writes it +// back. The write is conditional on the secret's resourceVersion, and a +// conflict reruns change against the current pool. +func updateJWTPool(ctx context.Context, secrets typedcorev1.SecretInterface, name string, change func(*localjwtauthority.ConcretePool) error) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + secret, err := secrets.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("while reading pool secret: %w", err) + } + wire, ok := secret.Data["pool"] + if !ok { + return fmt.Errorf("secret %s/%s has no \"pool\" key", secret.Namespace, secret.Name) + } + pool, err := localjwtauthority.Unmarshal(wire) + if err != nil { + return fmt.Errorf("while parsing pool: %w", err) + } + + if err := change(pool); err != nil { + return err + } + + wire, err = localjwtauthority.Marshal(pool) + if err != nil { + return fmt.Errorf("while marshaling pool: %w", err) + } + secret.Data["pool"] = wire + if _, err := secrets.Update(ctx, secret, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("while writing pool secret: %w", err) + } + return nil + }) +} + +// newKubeClient builds a client from the --kubeconfig and --context flags. +func newKubeClient() (kubernetes.Interface, error) { + kconfig, err := ateclient.LoadKubeConfig(kubeconfig, k8sContext) + if err != nil { + return nil, fmt.Errorf("while reading kubeconfig: %w", err) + } + kc, err := kubernetes.NewForConfig(kconfig) + if err != nil { + return nil, fmt.Errorf("while creating Kubernetes client: %w", err) + } + return kc, nil +} + // newJWTPoolSecret builds a Secret holding a pool with one active authority, // and returns the authority's key ID. func newJWTPoolSecret(namespace, name, algorithm, keyID string) (*corev1.Secret, string, error) { @@ -180,10 +293,26 @@ func init() { _ = makeCaPoolCmd.MarkFlagRequired("name") adminCmd.AddCommand(makeCaPoolCmd) - makeJwtPoolCmd.Flags().StringVar(&makeJwtPoolAlgFlag, "alg", "ES256", "Signing algorithm of the initial key. One of [ES256, RS256]") - makeJwtPoolCmd.Flags().StringVar(&makeJwtPoolKeyIDFlag, "key-id", "", "The ID of the initial JWT signing key in the pool. Defaults to the base64url SHA-256 of the key's PKIX encoding") + makeJwtPoolCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the initial key. One of [ES256, RS256]") + makeJwtPoolCmd.Flags().StringVar(&jwtKeyIDFlag, "key-id", "", "The ID of the initial JWT signing key in the pool. Defaults to the base64url SHA-256 of the key's PKIX encoding") makeJwtPoolCmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "Create the secret in this namespace") makeJwtPoolCmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "Create the secret with this name") _ = makeJwtPoolCmd.MarkFlagRequired("name") adminCmd.AddCommand(makeJwtPoolCmd) + + addJwtKeyCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the new key. One of [ES256, RS256]") + addJwtKeyCmd.Flags().StringVar(&jwtKeyIDFlag, "key-id", "", "The ID of the new key. Defaults to the base64url SHA-256 of the key's PKIX encoding") + addJwtKeyCmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "The namespace of the pool secret") + addJwtKeyCmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "The name of the pool secret") + _ = addJwtKeyCmd.MarkFlagRequired("name") + adminCmd.AddCommand(addJwtKeyCmd) + + for _, cmd := range []*cobra.Command{activateJwtKeyCmd, removeJwtKeyCmd} { + cmd.Flags().StringVar(&jwtKeyIDFlag, "key-id", "", "The ID of the key") + cmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "The namespace of the pool secret") + cmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "The name of the pool secret") + _ = cmd.MarkFlagRequired("key-id") + _ = cmd.MarkFlagRequired("name") + adminCmd.AddCommand(cmd) + } } diff --git a/cmd/kubectl-ate/internal/cmd/admin_test.go b/cmd/kubectl-ate/internal/cmd/admin_test.go index 6060f641f5..e32dbe4bd2 100644 --- a/cmd/kubectl-ate/internal/cmd/admin_test.go +++ b/cmd/kubectl-ate/internal/cmd/admin_test.go @@ -15,8 +15,18 @@ package cmd import ( + "context" "testing" + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + k8errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "github.com/agent-substrate/substrate/internal/localjwtauthority" ) @@ -70,3 +80,149 @@ func TestNewJWTPoolSecretRejectsUnsupportedAlgorithm(t *testing.T) { t.Error("newJWTPoolSecret(HS256) returned nil error") } } + +// poolState reads back the key IDs and active key of the pool secret. +func poolState(t *testing.T, kc *fake.Clientset) ([]string, string) { + t.Helper() + secret, err := kc.CoreV1().Secrets("ate-system").Get(context.Background(), "actor-id-jwt-pool", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + pool, err := localjwtauthority.Unmarshal(secret.Data["pool"]) + if err != nil { + t.Fatal(err) + } + var ids []string + for _, a := range pool.Authorities { + ids = append(ids, a.ID) + } + return ids, pool.ActiveForSigning +} + +func newPoolClientset(t *testing.T) *fake.Clientset { + t.Helper() + secret, _, err := newJWTPoolSecret("ate-system", "actor-id-jwt-pool", "ES256", "old") + if err != nil { + t.Fatal(err) + } + return fake.NewClientset(secret) +} + +func TestUpdateJWTPoolRotation(t *testing.T) { + kc := newPoolClientset(t) + secrets := kc.CoreV1().Secrets("ate-system") + ctx := context.Background() + next, err := localjwtauthority.GenerateAuthority("RS256", "next") + if err != nil { + t.Fatal(err) + } + + for _, step := range []struct { + name string + change func(*localjwtauthority.ConcretePool) error + wantIDs []string + wantActive string + }{ + {"add", func(p *localjwtauthority.ConcretePool) error { return p.AddAuthority(next) }, []string{"old", "next"}, "old"}, + {"activate", func(p *localjwtauthority.ConcretePool) error { return p.Activate("next") }, []string{"old", "next"}, "next"}, + {"remove", func(p *localjwtauthority.ConcretePool) error { return p.RemoveAuthority("old") }, []string{"next"}, "next"}, + } { + if err := updateJWTPool(ctx, secrets, "actor-id-jwt-pool", step.change); err != nil { + t.Fatalf("%s: %v", step.name, err) + } + ids, active := poolState(t, kc) + if diff := cmp.Diff(step.wantIDs, ids); diff != "" || active != step.wantActive { + t.Errorf("after %s: active %q, want %q; key IDs (-want +got):\n%s", step.name, active, step.wantActive, diff) + } + } +} + +func TestUpdateJWTPoolRetriesConflict(t *testing.T) { + kc := newPoolClientset(t) + conflicted := false + kc.PrependReactor("update", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) { + if conflicted { + return false, nil, nil + } + conflicted = true + // Another writer added a key between our read and write. + secret, _, err := newJWTPoolSecret("ate-system", "actor-id-jwt-pool", "ES256", "old") + if err != nil { + t.Fatal(err) + } + pool, err := localjwtauthority.Unmarshal(secret.Data["pool"]) + if err != nil { + t.Fatal(err) + } + other, err := localjwtauthority.GenerateAuthority("ES256", "other") + if err != nil { + t.Fatal(err) + } + if err := pool.AddAuthority(other); err != nil { + t.Fatal(err) + } + if secret.Data["pool"], err = localjwtauthority.Marshal(pool); err != nil { + t.Fatal(err) + } + if err := kc.Tracker().Update(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, secret, "ate-system"); err != nil { + t.Fatal(err) + } + return true, nil, k8errors.NewConflict(schema.GroupResource{Resource: "secrets"}, "actor-id-jwt-pool", nil) + }) + + next, err := localjwtauthority.GenerateAuthority("ES256", "next") + if err != nil { + t.Fatal(err) + } + if err := updateJWTPool(context.Background(), kc.CoreV1().Secrets("ate-system"), "actor-id-jwt-pool", func(p *localjwtauthority.ConcretePool) error { + return p.AddAuthority(next) + }); err != nil { + t.Fatalf("updateJWTPool: %v", err) + } + ids, _ := poolState(t, kc) + if diff := cmp.Diff([]string{"old", "other", "next"}, ids); diff != "" { + t.Errorf("key IDs (-want +got):\n%s", diff) + } +} + +func TestUpdateJWTPoolLeavesSecretOnError(t *testing.T) { + for _, tc := range []struct { + name string + secret *corev1.Secret + }{ + {name: "change refused"}, + {name: "no pool key", secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "ate-system", Name: "actor-id-jwt-pool"}, + Data: map[string][]byte{"other": []byte("x")}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + kc := newPoolClientset(t) + if tc.secret != nil { + kc = fake.NewClientset(tc.secret) + } + err := updateJWTPool(context.Background(), kc.CoreV1().Secrets("ate-system"), "actor-id-jwt-pool", func(p *localjwtauthority.ConcretePool) error { + return p.RemoveAuthority("old") + }) + if err == nil { + t.Error("got nil error") + } + for _, action := range kc.Actions() { + if action.GetVerb() == "update" { + t.Errorf("secret was written: %v", action) + } + } + }) + } +} + +func TestUpdateJWTPoolMissingSecret(t *testing.T) { + kc := fake.NewClientset() + err := updateJWTPool(context.Background(), kc.CoreV1().Secrets("ate-system"), "actor-id-jwt-pool", func(*localjwtauthority.ConcretePool) error { + t.Error("change ran without a pool") + return nil + }) + if !k8errors.IsNotFound(err) { + t.Errorf("err = %v, want NotFound", err) + } +} diff --git a/internal/localjwtauthority/localjwtauthority.go b/internal/localjwtauthority/localjwtauthority.go index 8db9683fbd..3aeafc148e 100644 --- a/internal/localjwtauthority/localjwtauthority.go +++ b/internal/localjwtauthority/localjwtauthority.go @@ -27,6 +27,7 @@ import ( "fmt" "hash" "os" + "slices" "sync" "time" @@ -189,6 +190,47 @@ func (p *ConcretePool) VerificationKeys() ([]*VerificationKey, error) { return keys, nil } +// AddAuthority adds authority to the pool without activating it, so relying +// parties can learn its key before it signs anything. +func (p *ConcretePool) AddAuthority(authority *Authority) error { + if authority.ID == "" { + return fmt.Errorf("authority has no ID") + } + if p.index(authority.ID) >= 0 { + return fmt.Errorf("authority %q already present", authority.ID) + } + p.Authorities = append(p.Authorities, authority) + return nil +} + +// Activate makes the authority with id the one that signs. +func (p *ConcretePool) Activate(id string) error { + if p.index(id) < 0 { + return fmt.Errorf("authority %q not present", id) + } + p.ActiveForSigning = id + return nil +} + +// RemoveAuthority removes the authority with id, which must not be the one +// that signs. +func (p *ConcretePool) RemoveAuthority(id string) error { + i := p.index(id) + if i < 0 { + return fmt.Errorf("authority %q not present", id) + } + if id == p.ActiveForSigning || (p.ActiveForSigning == "" && i == 0) { + return fmt.Errorf("authority %q is active for signing", id) + } + p.Authorities = slices.Delete(p.Authorities, i, i+1) + return nil +} + +// index returns the position of the authority with id, or -1. +func (p *ConcretePool) index(id string) int { + return slices.IndexFunc(p.Authorities, func(a *Authority) bool { return a.ID == id }) +} + type wireHeader struct { Type string `json:"typ,omitempty"` Algorithm string `json:"alg,omitempty"` diff --git a/internal/localjwtauthority/localjwtauthority_test.go b/internal/localjwtauthority/localjwtauthority_test.go index ee5cd4fc2c..f0522ccf45 100644 --- a/internal/localjwtauthority/localjwtauthority_test.go +++ b/internal/localjwtauthority/localjwtauthority_test.go @@ -190,6 +190,101 @@ func TestSignJWTHeader(t *testing.T) { } } +func authorityIDs(p *ConcretePool) []string { + var ids []string + for _, a := range p.Authorities { + ids = append(ids, a.ID) + } + return ids +} + +func testPool(t *testing.T, ids ...string) *ConcretePool { + t.Helper() + pool := &ConcretePool{ActiveForSigning: ids[0]} + for _, id := range ids { + authority, err := GenerateAuthority("ES256", id) + if err != nil { + t.Fatal(err) + } + pool.Authorities = append(pool.Authorities, authority) + } + return pool +} + +func TestPoolRotation(t *testing.T) { + pool := testPool(t, "old") + next, err := GenerateAuthority("RS256", "next") + if err != nil { + t.Fatal(err) + } + + if err := pool.AddAuthority(next); err != nil { + t.Fatalf("AddAuthority: %v", err) + } + if pool.ActiveForSigning != "old" { + t.Errorf("after AddAuthority, active = %q, want old", pool.ActiveForSigning) + } + if err := pool.Activate("next"); err != nil { + t.Fatalf("Activate: %v", err) + } + if err := pool.RemoveAuthority("old"); err != nil { + t.Fatalf("RemoveAuthority: %v", err) + } + + if diff := cmp.Diff([]string{"next"}, authorityIDs(pool)); diff != "" { + t.Errorf("authorities (-want +got):\n%s", diff) + } + jwt, err := pool.SignJWT(&actoridjwt.Claims{Subject: "actor/a/b", Audiences: []string{"aud"}}) + if err != nil { + t.Fatalf("SignJWT: %v", err) + } + header, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[0]) + if err != nil { + t.Fatal(err) + } + if want := `{"typ":"JWT","alg":"RS256","kid":"next"}`; string(header) != want { + t.Errorf("header = %s, want %s", header, want) + } +} + +func TestPoolRotationRejects(t *testing.T) { + dup, err := GenerateAuthority("ES256", "1") + if err != nil { + t.Fatal(err) + } + noID := &Authority{Algorithm: "ES256", SigningKey: dup.SigningKey} + + for _, tc := range []struct { + name string + pool *ConcretePool + change func(*ConcretePool) error + }{ + {name: "add duplicate ID", pool: testPool(t, "1", "2"), change: func(p *ConcretePool) error { return p.AddAuthority(dup) }}, + {name: "add without ID", pool: testPool(t, "1"), change: func(p *ConcretePool) error { return p.AddAuthority(noID) }}, + {name: "activate absent", pool: testPool(t, "1"), change: func(p *ConcretePool) error { return p.Activate("2") }}, + {name: "remove absent", pool: testPool(t, "1"), change: func(p *ConcretePool) error { return p.RemoveAuthority("2") }}, + {name: "remove active", pool: testPool(t, "1", "2"), change: func(p *ConcretePool) error { return p.RemoveAuthority("1") }}, + {name: "remove first with none designated", pool: func() *ConcretePool { + p := testPool(t, "1", "2") + p.ActiveForSigning = "" + return p + }(), change: func(p *ConcretePool) error { return p.RemoveAuthority("1") }}, + } { + t.Run(tc.name, func(t *testing.T) { + wantIDs, wantActive := authorityIDs(tc.pool), tc.pool.ActiveForSigning + if err := tc.change(tc.pool); err == nil { + t.Error("got nil error") + } + if diff := cmp.Diff(wantIDs, authorityIDs(tc.pool)); diff != "" { + t.Errorf("authorities changed (-want +got):\n%s", diff) + } + if tc.pool.ActiveForSigning != wantActive { + t.Errorf("active = %q, want %q", tc.pool.ActiveForSigning, wantActive) + } + }) + } +} + func TestGenerateAuthority(t *testing.T) { for _, alg := range []string{"RS256", "ES256"} { t.Run(alg, func(t *testing.T) { From fa68a1164663178ba836d8d77646e1fa2e817ac5 Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Fri, 2 Oct 2026 14:51:12 -0700 Subject: [PATCH 2/4] kubectl-ate: list the keys in a JWT authority pool A rotation ends by removing the old key by ID, and nothing showed the pool's key IDs short of decoding the Secret, which prints the private keys. list-jwt-keys prints each key's ID and algorithm and marks the one that signs. ConcretePool.ActiveID names the signing key, falling back to the first authority when none is designated as SignJWT does, and RemoveAuthority now uses it too so the listing and the removal check agree. --- cmd/kubectl-ate/README.md | 4 +- cmd/kubectl-ate/internal/cmd/admin.go | 70 ++++++++++++++++--- cmd/kubectl-ate/internal/cmd/admin_test.go | 31 ++++++++ .../localjwtauthority/localjwtauthority.go | 11 ++- .../localjwtauthority_test.go | 21 ++++++ 5 files changed, 124 insertions(+), 13 deletions(-) diff --git a/cmd/kubectl-ate/README.md b/cmd/kubectl-ate/README.md index 03ec343604..32f5e4fa11 100644 --- a/cmd/kubectl-ate/README.md +++ b/cmd/kubectl-ate/README.md @@ -322,7 +322,9 @@ kubectl ate admin make-jwt-pool \ # Rotate the JWT signing key. The new key is published but signs nothing until # activated; activate it once relying parties have refetched the key set, and -# remove the old key once every token it signed has expired. +# remove the old key once every token it signed has expired. list-jwt-keys +# shows each key's ID and algorithm and marks the active one. +kubectl ate admin list-jwt-keys --name actor-id-jwt-pool --secret-namespace ate-system kubectl ate admin add-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system kubectl ate admin activate-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system --key-id kubectl ate admin remove-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system --key-id diff --git a/cmd/kubectl-ate/internal/cmd/admin.go b/cmd/kubectl-ate/internal/cmd/admin.go index a831176df9..4a59d162dc 100644 --- a/cmd/kubectl-ate/internal/cmd/admin.go +++ b/cmd/kubectl-ate/internal/cmd/admin.go @@ -17,6 +17,8 @@ package cmd import ( "context" "fmt" + "io" + "text/tabwriter" "time" "github.com/agent-substrate/substrate/internal/ateclient" @@ -143,6 +145,23 @@ var makeJwtPoolCmd = &cobra.Command{ }, } +var listJwtKeysCmd = &cobra.Command{ + Use: "list-jwt-keys", + Short: "List the keys in a JWT authority pool secret", + RunE: func(cmd *cobra.Command, args []string) error { + kc, err := newKubeClient() + if err != nil { + return err + } + + _, pool, err := getJWTPool(cmd.Context(), kc.CoreV1().Secrets(poolSecretNamespaceFlag), poolSecretNameFlag) + if err != nil { + return err + } + return printJWTKeys(cmd.OutOrStdout(), pool) + }, +} + var addJwtKeyCmd = &cobra.Command{ Use: "add-jwt-key", Short: "Add an inactive signing key to a JWT authority pool secret", @@ -221,24 +240,16 @@ so remove it only after the last of them has expired.`, // conflict reruns change against the current pool. func updateJWTPool(ctx context.Context, secrets typedcorev1.SecretInterface, name string, change func(*localjwtauthority.ConcretePool) error) error { return retry.RetryOnConflict(retry.DefaultRetry, func() error { - secret, err := secrets.Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("while reading pool secret: %w", err) - } - wire, ok := secret.Data["pool"] - if !ok { - return fmt.Errorf("secret %s/%s has no \"pool\" key", secret.Namespace, secret.Name) - } - pool, err := localjwtauthority.Unmarshal(wire) + secret, pool, err := getJWTPool(ctx, secrets, name) if err != nil { - return fmt.Errorf("while parsing pool: %w", err) + return err } if err := change(pool); err != nil { return err } - wire, err = localjwtauthority.Marshal(pool) + wire, err := localjwtauthority.Marshal(pool) if err != nil { return fmt.Errorf("while marshaling pool: %w", err) } @@ -250,6 +261,38 @@ func updateJWTPool(ctx context.Context, secrets typedcorev1.SecretInterface, nam }) } +// getJWTPool reads the named secret and parses the pool it holds. +func getJWTPool(ctx context.Context, secrets typedcorev1.SecretInterface, name string) (*corev1.Secret, *localjwtauthority.ConcretePool, error) { + secret, err := secrets.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("while reading pool secret: %w", err) + } + wire, ok := secret.Data["pool"] + if !ok { + return nil, nil, fmt.Errorf("secret %s/%s has no \"pool\" key", secret.Namespace, secret.Name) + } + pool, err := localjwtauthority.Unmarshal(wire) + if err != nil { + return nil, nil, fmt.Errorf("while parsing pool: %w", err) + } + return secret, pool, nil +} + +// printJWTKeys prints the ID and algorithm of every key in the pool, marking +// the one that signs. It prints nothing about the private keys. +func printJWTKeys(out io.Writer, pool *localjwtauthority.ConcretePool) error { + w := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "ACTIVE\tKEY ID\tALGORITHM") + for _, authority := range pool.Authorities { + active := "" + if authority.ID == pool.ActiveID() { + active = "*" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", active, authority.ID, authority.Algorithm) + } + return w.Flush() +} + // newKubeClient builds a client from the --kubeconfig and --context flags. func newKubeClient() (kubernetes.Interface, error) { kconfig, err := ateclient.LoadKubeConfig(kubeconfig, k8sContext) @@ -300,6 +343,11 @@ func init() { _ = makeJwtPoolCmd.MarkFlagRequired("name") adminCmd.AddCommand(makeJwtPoolCmd) + listJwtKeysCmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "The namespace of the pool secret") + listJwtKeysCmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "The name of the pool secret") + _ = listJwtKeysCmd.MarkFlagRequired("name") + adminCmd.AddCommand(listJwtKeysCmd) + addJwtKeyCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the new key. One of [ES256, RS256]") addJwtKeyCmd.Flags().StringVar(&jwtKeyIDFlag, "key-id", "", "The ID of the new key. Defaults to the base64url SHA-256 of the key's PKIX encoding") addJwtKeyCmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "The namespace of the pool secret") diff --git a/cmd/kubectl-ate/internal/cmd/admin_test.go b/cmd/kubectl-ate/internal/cmd/admin_test.go index e32dbe4bd2..143adab735 100644 --- a/cmd/kubectl-ate/internal/cmd/admin_test.go +++ b/cmd/kubectl-ate/internal/cmd/admin_test.go @@ -16,6 +16,7 @@ package cmd import ( "context" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -226,3 +227,33 @@ func TestUpdateJWTPoolMissingSecret(t *testing.T) { t.Errorf("err = %v, want NotFound", err) } } + +func TestListJWTKeys(t *testing.T) { + kc := newPoolClientset(t) + secrets := kc.CoreV1().Secrets("ate-system") + next, err := localjwtauthority.GenerateAuthority("RS256", "next") + if err != nil { + t.Fatal(err) + } + if err := updateJWTPool(context.Background(), secrets, "actor-id-jwt-pool", func(p *localjwtauthority.ConcretePool) error { + return p.AddAuthority(next) + }); err != nil { + t.Fatal(err) + } + + _, pool, err := getJWTPool(context.Background(), secrets, "actor-id-jwt-pool") + if err != nil { + t.Fatal(err) + } + var out strings.Builder + if err := printJWTKeys(&out, pool); err != nil { + t.Fatal(err) + } + want := "" + + "ACTIVE KEY ID ALGORITHM\n" + + "* old ES256\n" + + " next RS256\n" + if diff := cmp.Diff(want, out.String()); diff != "" { + t.Errorf("output (-want +got):\n%s", diff) + } +} diff --git a/internal/localjwtauthority/localjwtauthority.go b/internal/localjwtauthority/localjwtauthority.go index 3aeafc148e..3c0dcdf889 100644 --- a/internal/localjwtauthority/localjwtauthority.go +++ b/internal/localjwtauthority/localjwtauthority.go @@ -219,13 +219,22 @@ func (p *ConcretePool) RemoveAuthority(id string) error { if i < 0 { return fmt.Errorf("authority %q not present", id) } - if id == p.ActiveForSigning || (p.ActiveForSigning == "" && i == 0) { + if id == p.ActiveID() { return fmt.Errorf("authority %q is active for signing", id) } p.Authorities = slices.Delete(p.Authorities, i, i+1) return nil } +// ActiveID returns the ID of the authority that signs: ActiveForSigning, or +// the first authority's when none is designated. +func (p *ConcretePool) ActiveID() string { + if p.ActiveForSigning == "" && len(p.Authorities) > 0 { + return p.Authorities[0].ID + } + return p.ActiveForSigning +} + // index returns the position of the authority with id, or -1. func (p *ConcretePool) index(id string) int { return slices.IndexFunc(p.Authorities, func(a *Authority) bool { return a.ID == id }) diff --git a/internal/localjwtauthority/localjwtauthority_test.go b/internal/localjwtauthority/localjwtauthority_test.go index f0522ccf45..dd649996ed 100644 --- a/internal/localjwtauthority/localjwtauthority_test.go +++ b/internal/localjwtauthority/localjwtauthority_test.go @@ -285,6 +285,27 @@ func TestPoolRotationRejects(t *testing.T) { } } +func TestPoolActiveID(t *testing.T) { + designated := testPool(t, "1", "2") + designated.ActiveForSigning = "2" + undesignated := testPool(t, "1", "2") + undesignated.ActiveForSigning = "" + + for _, tc := range []struct { + name string + pool *ConcretePool + want string + }{ + {name: "designated", pool: designated, want: "2"}, + {name: "none designated", pool: undesignated, want: "1"}, + {name: "empty", pool: &ConcretePool{}, want: ""}, + } { + if got := tc.pool.ActiveID(); got != tc.want { + t.Errorf("%s: ActiveID() = %q, want %q", tc.name, got, tc.want) + } + } +} + func TestGenerateAuthority(t *testing.T) { for _, alg := range []string{"RS256", "ES256"} { t.Run(alg, func(t *testing.T) { From 6ff1a064bfad15253db25aead8eb9a3b4f5ecbda Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Tue, 6 Oct 2026 09:48:39 -0700 Subject: [PATCH 3/4] localjwtauthority: generate and accept only 4096-bit RSA keys RS256 does not fix an RSA key size, and the 2048-bit keys GenerateAuthority made are too weak for the actor JWT signing key. RS256 is kept only for relying parties that require it, such as Azure workload identity federation; ES256 stays the default. GenerateAuthority now makes 4096-bit RSA keys, and a pool refuses an RSA key of any other size, both when one is added and when the pool Secret is loaded. A pool holding a 2048-bit key fails to load until the key is rotated out. --- cmd/kubectl-ate/README.md | 6 ++-- cmd/kubectl-ate/internal/cmd/admin.go | 4 +-- hack/install-ate.sh | 2 +- .../localjwtauthority/localjwtauthority.go | 20 ++++++++++++- .../localjwtauthority_test.go | 30 +++++++++++++++++-- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/cmd/kubectl-ate/README.md b/cmd/kubectl-ate/README.md index 32f5e4fa11..9da5c3ca7a 100644 --- a/cmd/kubectl-ate/README.md +++ b/cmd/kubectl-ate/README.md @@ -313,9 +313,9 @@ kubectl ate admin make-ca-pool \ --ca-id "1" # Generate a new JWT authority pool and push it to a Kubernetes Secret. The -# key is ES256 (--alg RS256 for relying parties that don't support ES256) and -# its ID defaults to the base64url SHA-256 of the public key's PKIX encoding -# (--key-id to override). +# key is ES256 (--alg RS256, a 4096-bit RSA key, for relying parties that don't +# support ES256) and its ID defaults to the base64url SHA-256 of the public +# key's PKIX encoding (--key-id to override). kubectl ate admin make-jwt-pool \ --name actor-id-jwt-pool \ --secret-namespace ate-system diff --git a/cmd/kubectl-ate/internal/cmd/admin.go b/cmd/kubectl-ate/internal/cmd/admin.go index 4a59d162dc..ab27bcfbb6 100644 --- a/cmd/kubectl-ate/internal/cmd/admin.go +++ b/cmd/kubectl-ate/internal/cmd/admin.go @@ -336,7 +336,7 @@ func init() { _ = makeCaPoolCmd.MarkFlagRequired("name") adminCmd.AddCommand(makeCaPoolCmd) - makeJwtPoolCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the initial key. One of [ES256, RS256]") + makeJwtPoolCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the initial key. One of [ES256, RS256]; RS256 keys are 4096-bit RSA") makeJwtPoolCmd.Flags().StringVar(&jwtKeyIDFlag, "key-id", "", "The ID of the initial JWT signing key in the pool. Defaults to the base64url SHA-256 of the key's PKIX encoding") makeJwtPoolCmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "Create the secret in this namespace") makeJwtPoolCmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "Create the secret with this name") @@ -348,7 +348,7 @@ func init() { _ = listJwtKeysCmd.MarkFlagRequired("name") adminCmd.AddCommand(listJwtKeysCmd) - addJwtKeyCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the new key. One of [ES256, RS256]") + addJwtKeyCmd.Flags().StringVar(&jwtAlgFlag, "alg", "ES256", "Signing algorithm of the new key. One of [ES256, RS256]; RS256 keys are 4096-bit RSA") addJwtKeyCmd.Flags().StringVar(&jwtKeyIDFlag, "key-id", "", "The ID of the new key. Defaults to the base64url SHA-256 of the key's PKIX encoding") addJwtKeyCmd.Flags().StringVar(&poolSecretNamespaceFlag, "secret-namespace", "default", "The namespace of the pool secret") addJwtKeyCmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "The name of the pool secret") diff --git a/hack/install-ate.sh b/hack/install-ate.sh index 8d046bce7b..4036be08cb 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -164,7 +164,7 @@ usage() { echo " (https://container.googleapis.com/v1/projects/.../clusters/...)," echo " else the cluster's OIDC discovery document" echo " ACTOR_JWT_ALGORITHM Signing algorithm of a newly created actor JWT pool: ES256 (default) | RS256." - echo " Use RS256 for relying parties that don't support ES256. Has no effect once the pool exists." + echo " Use RS256 (a 4096-bit RSA key) for relying parties that don't support ES256. Has no effect once the pool exists." echo "" echo "Benchmarks (see benchmarking/README.md for details and customization):" echo "" diff --git a/internal/localjwtauthority/localjwtauthority.go b/internal/localjwtauthority/localjwtauthority.go index 3c0dcdf889..180d49f8e7 100644 --- a/internal/localjwtauthority/localjwtauthority.go +++ b/internal/localjwtauthority/localjwtauthority.go @@ -196,6 +196,9 @@ func (p *ConcretePool) AddAuthority(authority *Authority) error { if authority.ID == "" { return fmt.Errorf("authority has no ID") } + if err := checkKeySize(authority.ID, authority.SigningKey); err != nil { + return err + } if p.index(authority.ID) >= 0 { return fmt.Errorf("authority %q already present", authority.ID) } @@ -382,6 +385,9 @@ func Unmarshal(wireBytes []byte) (*ConcretePool, error) { // All key types from ParsePKCS8PrivateKey implement Signer authority.SigningKey = key.(crypto.Signer) + if err := checkKeySize(authority.ID, authority.SigningKey); err != nil { + return nil, err + } pool.Authorities = append(pool.Authorities, authority) } @@ -389,6 +395,18 @@ func Unmarshal(wireBytes []byte) (*ConcretePool, error) { return pool, nil } +// rsaKeyBits is the only RSA key size a pool accepts. RS256 does not fix one, +// and smaller keys are too weak. +const rsaKeyBits = 4096 + +// checkKeySize refuses an RSA key of any size other than rsaKeyBits. +func checkKeySize(id string, key crypto.Signer) error { + if k, ok := key.(*rsa.PrivateKey); ok && k.N.BitLen() != rsaKeyBits { + return fmt.Errorf("authority %q has a %d-bit RSA key; only %d-bit RSA keys are supported", id, k.N.BitLen(), rsaKeyBits) + } + return nil +} + // GenerateAuthority generates a JWT signing key for algorithm, which must be // RS256 or ES256. An empty id defaults to the Thumbprint of the public key. func GenerateAuthority(algorithm, id string) (*Authority, error) { @@ -396,7 +414,7 @@ func GenerateAuthority(algorithm, id string) (*Authority, error) { var err error switch algorithm { case "RS256": - key, err = rsa.GenerateKey(rand.Reader, 2048) + key, err = rsa.GenerateKey(rand.Reader, rsaKeyBits) case "ES256": key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) default: diff --git a/internal/localjwtauthority/localjwtauthority_test.go b/internal/localjwtauthority/localjwtauthority_test.go index dd649996ed..21e0d81a3e 100644 --- a/internal/localjwtauthority/localjwtauthority_test.go +++ b/internal/localjwtauthority/localjwtauthority_test.go @@ -17,6 +17,7 @@ package localjwtauthority import ( "crypto/ecdsa" "crypto/elliptic" + "crypto/rand" "crypto/rsa" "encoding/base64" "encoding/json" @@ -325,8 +326,8 @@ func TestGenerateAuthority(t *testing.T) { } switch key := authority.SigningKey.(type) { case *rsa.PrivateKey: - if alg != "RS256" || key.N.BitLen() != 2048 { - t.Errorf("got a %d-bit RSA key for %s, want 2048-bit for RS256", key.N.BitLen(), alg) + if alg != "RS256" || key.N.BitLen() != 4096 { + t.Errorf("got a %d-bit RSA key for %s, want 4096-bit for RS256", key.N.BitLen(), alg) } case *ecdsa.PrivateKey: if alg != "ES256" || key.Curve != elliptic.P256() { @@ -432,3 +433,28 @@ func TestVerificationKeysCarryAlgorithm(t *testing.T) { t.Errorf("verification key algorithms (-want +got):\n%s", diff) } } + +func TestRejectSmallRSAKeys(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + weak := &Authority{ID: "weak", Algorithm: "RS256", SigningKey: key} + + es, err := GenerateAuthority("ES256", "es") + if err != nil { + t.Fatal(err) + } + pool := &ConcretePool{Authorities: []*Authority{es}, ActiveForSigning: es.ID} + if err := pool.AddAuthority(weak); err == nil || !strings.Contains(err.Error(), "2048-bit RSA key") { + t.Errorf("AddAuthority(2048-bit RSA key) = %v, want a key size error", err) + } + + wire, err := Marshal(&ConcretePool{Authorities: []*Authority{weak}, ActiveForSigning: weak.ID}) + if err != nil { + t.Fatal(err) + } + if _, err := Unmarshal(wire); err == nil || !strings.Contains(err.Error(), "2048-bit RSA key") { + t.Errorf("Unmarshal(pool with a 2048-bit RSA key) = %v, want a key size error", err) + } +} From 3892bda8ca2c0a68eeb2940e5d07c39f1f07457b Mon Sep 17 00:00:00 2001 From: Max Thompson Date: Thu, 8 Oct 2026 09:22:40 -0700 Subject: [PATCH 4/4] localjwtauthority: only generation fixes the RSA key size Pools no longer refuse RSA keys other than 4096-bit when adding a key or loading the pool. GenerateAuthority still makes 4096-bit keys, which is what the CLI and setup use; an operator who builds a pool by hand with a different size is not stopped. --- .../localjwtauthority/localjwtauthority.go | 18 ++----------- .../localjwtauthority_test.go | 26 ------------------- 2 files changed, 2 insertions(+), 42 deletions(-) diff --git a/internal/localjwtauthority/localjwtauthority.go b/internal/localjwtauthority/localjwtauthority.go index 180d49f8e7..6e3fc5adc2 100644 --- a/internal/localjwtauthority/localjwtauthority.go +++ b/internal/localjwtauthority/localjwtauthority.go @@ -196,9 +196,6 @@ func (p *ConcretePool) AddAuthority(authority *Authority) error { if authority.ID == "" { return fmt.Errorf("authority has no ID") } - if err := checkKeySize(authority.ID, authority.SigningKey); err != nil { - return err - } if p.index(authority.ID) >= 0 { return fmt.Errorf("authority %q already present", authority.ID) } @@ -385,9 +382,6 @@ func Unmarshal(wireBytes []byte) (*ConcretePool, error) { // All key types from ParsePKCS8PrivateKey implement Signer authority.SigningKey = key.(crypto.Signer) - if err := checkKeySize(authority.ID, authority.SigningKey); err != nil { - return nil, err - } pool.Authorities = append(pool.Authorities, authority) } @@ -395,18 +389,10 @@ func Unmarshal(wireBytes []byte) (*ConcretePool, error) { return pool, nil } -// rsaKeyBits is the only RSA key size a pool accepts. RS256 does not fix one, -// and smaller keys are too weak. +// rsaKeyBits is the size of the RSA keys GenerateAuthority makes. RS256 does +// not fix one, and smaller keys are too weak. const rsaKeyBits = 4096 -// checkKeySize refuses an RSA key of any size other than rsaKeyBits. -func checkKeySize(id string, key crypto.Signer) error { - if k, ok := key.(*rsa.PrivateKey); ok && k.N.BitLen() != rsaKeyBits { - return fmt.Errorf("authority %q has a %d-bit RSA key; only %d-bit RSA keys are supported", id, k.N.BitLen(), rsaKeyBits) - } - return nil -} - // GenerateAuthority generates a JWT signing key for algorithm, which must be // RS256 or ES256. An empty id defaults to the Thumbprint of the public key. func GenerateAuthority(algorithm, id string) (*Authority, error) { diff --git a/internal/localjwtauthority/localjwtauthority_test.go b/internal/localjwtauthority/localjwtauthority_test.go index 21e0d81a3e..4b3e8e5243 100644 --- a/internal/localjwtauthority/localjwtauthority_test.go +++ b/internal/localjwtauthority/localjwtauthority_test.go @@ -17,7 +17,6 @@ package localjwtauthority import ( "crypto/ecdsa" "crypto/elliptic" - "crypto/rand" "crypto/rsa" "encoding/base64" "encoding/json" @@ -433,28 +432,3 @@ func TestVerificationKeysCarryAlgorithm(t *testing.T) { t.Errorf("verification key algorithms (-want +got):\n%s", diff) } } - -func TestRejectSmallRSAKeys(t *testing.T) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - weak := &Authority{ID: "weak", Algorithm: "RS256", SigningKey: key} - - es, err := GenerateAuthority("ES256", "es") - if err != nil { - t.Fatal(err) - } - pool := &ConcretePool{Authorities: []*Authority{es}, ActiveForSigning: es.ID} - if err := pool.AddAuthority(weak); err == nil || !strings.Contains(err.Error(), "2048-bit RSA key") { - t.Errorf("AddAuthority(2048-bit RSA key) = %v, want a key size error", err) - } - - wire, err := Marshal(&ConcretePool{Authorities: []*Authority{weak}, ActiveForSigning: weak.ID}) - if err != nil { - t.Fatal(err) - } - if _, err := Unmarshal(wire); err == nil || !strings.Contains(err.Error(), "2048-bit RSA key") { - t.Errorf("Unmarshal(pool with a 2048-bit RSA key) = %v, want a key size error", err) - } -}