Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions cmd/kubectl-ate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,19 @@ 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 RFC 7638 thumbprint (--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

# 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. 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 <new key ID>
kubectl ate admin remove-jwt-key --name actor-id-jwt-pool --secret-namespace ate-system --key-id <old key ID>
```
217 changes: 197 additions & 20 deletions cmd/kubectl-ate/internal/cmd/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
package cmd

import (
"context"
"fmt"
"io"
"text/tabwriter"
"time"

"github.com/agent-substrate/substrate/internal/ateclient"
Expand All @@ -25,17 +28,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{
Expand All @@ -49,14 +54,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
Expand Down Expand Up @@ -125,31 +125,187 @@ 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
}

kc, err := kubernetes.NewForConfig(kconfig)
secret, keyID, err := newJWTPoolSecret(poolSecretNamespaceFlag, poolSecretNameFlag, jwtAlgFlag, jwtKeyIDFlag)
if err != nil {
return fmt.Errorf("while creating Kubernetes client: %w", err)
return err
}

secret, keyID, err := newJWTPoolSecret(poolSecretNamespaceFlag, poolSecretNameFlag, makeJwtPoolAlgFlag, makeJwtPoolKeyIDFlag)
_, err = kc.CoreV1().Secrets(poolSecretNamespaceFlag).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("while uploading pool state to secret: %w", err)
}

fmt.Printf("Successfully created JWT authority pool secret %s/%s with %s key %s\n", poolSecretNamespaceFlag, poolSecretNameFlag, jwtAlgFlag, keyID)
return nil
},
}

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
}

_, err = kc.CoreV1().Secrets(poolSecretNamespaceFlag).Create(ctx, secret, metav1.CreateOptions{})
_, pool, err := getJWTPool(cmd.Context(), kc.CoreV1().Secrets(poolSecretNamespaceFlag), poolSecretNameFlag)
if err != nil {
return fmt.Errorf("while uploading pool state to secret: %w", err)
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",
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
}

authority, err := localjwtauthority.GenerateAuthority(jwtAlgFlag, jwtKeyIDFlag)
if err != nil {
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
}

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("Successfully created JWT authority pool secret %s/%s with %s key %s\n", poolSecretNamespaceFlag, poolSecretNameFlag, makeJwtPoolAlgFlag, keyID)
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, pool, err := getJWTPool(ctx, secrets, name)
if err != nil {
return 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
})
}

// 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)
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) {
Expand Down Expand Up @@ -180,10 +336,31 @@ 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 key's RFC 7638 thumbprint")
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")
_ = 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]; 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")
_ = 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)
}
}
Loading
Loading