Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/ate-setup/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ type Config struct {
// follows neither form.
ExpectedJWTIssuer string

// ActorJWTAlgorithm is the signing algorithm of the key in a new actor JWT
// pool (ACTOR_JWT_ALGORITHM): ES256 or RS256.
ActorJWTAlgorithm string

// BucketName is the snapshot bucket demos are templated with.
BucketName string

Expand Down Expand Up @@ -345,6 +349,7 @@ func Load(opts Options) (*Config, error) {
ClusterName: env["CLUSTER_NAME"],
ClusterLocation: env["CLUSTER_LOCATION"],
ExpectedJWTIssuer: env["EXPECTED_JWT_ISSUER"],
ActorJWTAlgorithm: firstNonEmpty(env["ACTOR_JWT_ALGORITHM"], "ES256"),
BucketName: env["BUCKET_NAME"],
KODockerRepo: env["KO_DOCKER_REPO"],
KODefaultPlatforms: env["KO_DEFAULTPLATFORMS"],
Expand Down Expand Up @@ -449,6 +454,11 @@ func validate(cfg *Config) error {
return fmt.Errorf("ATE_API_POSTGRES_CLOUDSQL_IP_TYPE must be %s, %s, or %s, got %q",
CloudSQLIPTypePrivate, CloudSQLIPTypePublic, CloudSQLIPTypePSC, cfg.CloudSQL.IPType)
}
switch cfg.ActorJWTAlgorithm {
case "ES256", "RS256":
default:
return fmt.Errorf("ACTOR_JWT_ALGORITHM must be ES256 or RS256, got %q", cfg.ActorJWTAlgorithm)
}
switch cfg.ClusterSize {
case ClusterSizeSize0, ClusterSizeSize10:
default:
Expand Down
33 changes: 33 additions & 0 deletions cmd/ate-setup/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func loadEnv(t *testing.T) {
t.Helper()
t.Setenv("NO_DEV_ENV", "1")
for _, name := range []string{
"ACTOR_JWT_ALGORITHM",
"ANTHROPIC_API_KEY",
"ATE_ADDITIONAL_EGRESS_EXTPROC_SERVICE",
"ATE_API_POSTGRES_CLOUDSQL_GSA",
Expand Down Expand Up @@ -380,6 +381,38 @@ func TestLoadExpectedJWTIssuer(t *testing.T) {
}
}

func TestLoadActorJWTAlgorithm(t *testing.T) {
for _, tt := range []struct {
name string
env string
want string
wantErr bool
}{
{name: "unset", env: "", want: "ES256"},
{name: "RS256", env: "RS256", want: "RS256"},
{name: "unsupported", env: "HS256", wantErr: true},
} {
t.Run(tt.name, func(t *testing.T) {
loadEnv(t)
t.Setenv("ACTOR_JWT_ALGORITHM", tt.env)

cfg, err := Load(Options{})
if tt.wantErr {
if err == nil {
t.Fatalf("Load() with ACTOR_JWT_ALGORITHM=%q returned nil error", tt.env)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.ActorJWTAlgorithm != tt.want {
t.Errorf("ActorJWTAlgorithm = %q, want %q", cfg.ActorJWTAlgorithm, tt.want)
}
})
}
}

// The endpoint has to reach both the Go steps and the shell scripts ate-setup
// still delegates to, or the two halves of an install export different
// collectors.
Expand Down
23 changes: 13 additions & 10 deletions cmd/ate-setup/internal/steps/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ const (
SecretPostgresServerCA = "postgres-server-ca"
ConfigMapAPIEnvVars = "ate-api-server-envvars"
ConfigMapAPIAuthn = "ate-api-authentication"
// poolKeyID is the identifier given to the first CA and JWT key in a new
// pool, matching the --ca-id/--key-id the shell scripts passed.
// poolKeyID is the identifier given to the first CA in a new pool,
// matching the --ca-id the shell scripts passed.
poolKeyID = "1"
)

Expand Down Expand Up @@ -259,18 +259,21 @@ func (e *Env) createJWTPool(ctx context.Context, namespace, name string) error {
return nil
}

authority, err := localjwtauthority.GenerateECDSAP256Authority(poolKeyID)
data, err := newJWTPoolSecretData(e.Cfg.ActorJWTAlgorithm)
if err != nil {
return fmt.Errorf("while generating the JWT authority for %s/%s: %w", namespace, name, err)
return fmt.Errorf("while building the JWT pool for %s/%s: %w", namespace, name, err)
}
poolBytes, err := localjwtauthority.Marshal(&localjwtauthority.ConcretePool{
Authorities: []*localjwtauthority.Authority{authority},
ActiveForSigning: poolKeyID,
})
return e.createPoolSecret(ctx, namespace, name, corev1.SecretTypeOpaque, data)
}

// newJWTPoolSecretData generates a pool with one active authority for
// algorithm, keyed by its thumbprint.
func newJWTPoolSecretData(algorithm string) (map[string][]byte, error) {
poolBytes, _, err := localjwtauthority.GeneratePool(algorithm, "")
if err != nil {
return fmt.Errorf("while marshaling the JWT pool for %s/%s: %w", namespace, name, err)
return nil, fmt.Errorf("while generating the JWT pool: %w", err)
}
return e.createPoolSecret(ctx, namespace, name, corev1.SecretTypeOpaque, map[string][]byte{"pool": poolBytes})
return map[string][]byte{"pool": poolBytes}, nil
}

// createPoolSecret writes pool state.
Expand Down
35 changes: 35 additions & 0 deletions cmd/ate-setup/internal/steps/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
corev1 "k8s.io/api/core/v1"

"github.com/agent-substrate/substrate/internal/localca"
"github.com/agent-substrate/substrate/internal/localjwtauthority"
"github.com/agent-substrate/substrate/internal/oidcdiscovery"
)

// ate-api-server requires both connection strings and its schema in the
Expand Down Expand Up @@ -160,3 +162,36 @@ func TestNewCAPoolSecretData(t *testing.T) {
})
}
}

func TestNewJWTPoolSecretData(t *testing.T) {
for _, alg := range []string{"ES256", "RS256"} {
t.Run(alg, func(t *testing.T) {
data, err := newJWTPoolSecretData(alg)
if err != nil {
t.Fatalf("newJWTPoolSecretData() error = %v", err)
}
if diff := cmp.Diff([]string{"pool"}, slices.Sorted(maps.Keys(data))); diff != "" {
t.Errorf("secret keys differ (-want +got):\n%s", diff)
}

pool, err := localjwtauthority.Unmarshal(data["pool"])
if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(pool.Authorities) != 1 {
t.Fatalf("pool has %d authorities, want 1", len(pool.Authorities))
}
authority := pool.Authorities[0]
if authority.Algorithm != alg {
t.Errorf("Algorithm = %q, want %q", authority.Algorithm, alg)
}
thumbprint, err := oidcdiscovery.Thumbprint(authority.SigningKey.Public())
if err != nil {
t.Fatalf("Thumbprint() error = %v", err)
}
if authority.ID != thumbprint || pool.ActiveForSigning != thumbprint {
t.Errorf("key ID %q, active %q; want both to be the thumbprint %q", authority.ID, pool.ActiveForSigning, thumbprint)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func setupTestWithVolumePlugins(t *testing.T, ns string, plugins map[string]volu
}
}

actorJWTAuthority, err := localjwtauthority.GenerateECDSAP256Authority("1")
actorJWTAuthority, err := localjwtauthority.GenerateAuthority("ES256", "1")
if err != nil {
t.Fatalf("Error generating actor JWT authority: %v", err)
}
Expand Down
7 changes: 4 additions & 3 deletions cmd/kubectl-ate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,10 @@ kubectl ate admin make-ca-pool \
--secret-namespace ate-system \
--ca-id "1"

# Generate a new JWT authority pool and push it to a Kubernetes Secret
# 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).
kubectl ate admin make-jwt-pool \
--name actor-id-jwt-pool \
--secret-namespace ate-system \
--key-id "1"
--secret-namespace ate-system
```
49 changes: 25 additions & 24 deletions cmd/kubectl-ate/internal/cmd/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ var (
makeCaPoolIDFlag string
makeCaPoolKeyTypeFlag string
makeCaPoolValidityFlag time.Duration
makeJwtPoolAlgFlag string
makeJwtPoolKeyIDFlag string
)

Expand Down Expand Up @@ -134,41 +135,40 @@ var makeJwtPoolCmd = &cobra.Command{
return fmt.Errorf("while creating Kubernetes client: %w", err)
}

authority, err := localjwtauthority.GenerateECDSAP256Authority(makeJwtPoolKeyIDFlag)
secret, keyID, err := newJWTPoolSecret(poolSecretNamespaceFlag, poolSecretNameFlag, makeJwtPoolAlgFlag, makeJwtPoolKeyIDFlag)
if err != nil {
return fmt.Errorf("while generating JWT authority: %w", err)
}

pool := &localjwtauthority.ConcretePool{
Authorities: []*localjwtauthority.Authority{authority},
ActiveForSigning: makeJwtPoolKeyIDFlag,
}

poolBytes, err := localjwtauthority.Marshal(pool)
if err != nil {
return fmt.Errorf("while marshaling pool: %w", err)
}

secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: poolSecretNamespaceFlag,
Name: poolSecretNameFlag,
},
Data: map[string][]byte{
"pool": poolBytes,
},
return err
}

_, 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\n", poolSecretNamespaceFlag, poolSecretNameFlag)
fmt.Printf("Successfully created JWT authority pool secret %s/%s with %s key %s\n", poolSecretNamespaceFlag, poolSecretNameFlag, makeJwtPoolAlgFlag, keyID)
return 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) {
poolBytes, id, err := localjwtauthority.GeneratePool(algorithm, keyID)
if err != nil {
return nil, "", fmt.Errorf("while generating JWT authority pool: %w", err)
}

return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: name,
},
Data: map[string][]byte{
"pool": poolBytes,
},
}, id, nil
}

func init() {
rootCmd.AddCommand(adminCmd)

Expand All @@ -180,7 +180,8 @@ func init() {
_ = makeCaPoolCmd.MarkFlagRequired("name")
adminCmd.AddCommand(makeCaPoolCmd)

makeJwtPoolCmd.Flags().StringVar(&makeJwtPoolKeyIDFlag, "key-id", "1", "The ID of the initial JWT signing key in the pool")
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(&poolSecretNamespaceFlag, "secret-namespace", "default", "Create the secret in this namespace")
makeJwtPoolCmd.Flags().StringVar(&poolSecretNameFlag, "name", "", "Create the secret with this name")
_ = makeJwtPoolCmd.MarkFlagRequired("name")
Expand Down
73 changes: 73 additions & 0 deletions cmd/kubectl-ate/internal/cmd/admin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"testing"

"github.com/agent-substrate/substrate/internal/localjwtauthority"
"github.com/agent-substrate/substrate/internal/oidcdiscovery"
)

func TestNewJWTPoolSecretWithFlagDefaults(t *testing.T) {
alg := makeJwtPoolCmd.Flags().Lookup("alg").DefValue
keyID := makeJwtPoolCmd.Flags().Lookup("key-id").DefValue

secret, gotKeyID, err := newJWTPoolSecret("ate-system", "actor-id-jwt-pool", alg, keyID)
if err != nil {
t.Fatal(err)
}
if secret.Namespace != "ate-system" || secret.Name != "actor-id-jwt-pool" {
t.Errorf("secret = %s/%s, want ate-system/actor-id-jwt-pool", secret.Namespace, secret.Name)
}
pool, err := localjwtauthority.Unmarshal(secret.Data["pool"])
if err != nil {
t.Fatal(err)
}
if len(pool.Authorities) != 1 {
t.Fatalf("pool has %d authorities, want 1", len(pool.Authorities))
}
authority := pool.Authorities[0]
if authority.Algorithm != "ES256" {
t.Errorf("Algorithm = %q, want ES256", authority.Algorithm)
}
thumbprint, err := oidcdiscovery.Thumbprint(authority.SigningKey.Public())
if err != nil {
t.Fatal(err)
}
if authority.ID != thumbprint || gotKeyID != thumbprint || pool.ActiveForSigning != thumbprint {
t.Errorf("key ID %q, returned %q, active %q; want all to be the thumbprint %q", authority.ID, gotKeyID, pool.ActiveForSigning, thumbprint)
}
}

func TestNewJWTPoolSecretExplicitKey(t *testing.T) {
secret, keyID, err := newJWTPoolSecret("ate-system", "actor-id-jwt-pool", "RS256", "1")
if err != nil {
t.Fatal(err)
}
pool, err := localjwtauthority.Unmarshal(secret.Data["pool"])
if err != nil {
t.Fatal(err)
}
if keyID != "1" || pool.ActiveForSigning != "1" || pool.Authorities[0].Algorithm != "RS256" {
t.Errorf("got key %q, active %q, algorithm %q; want 1, 1, RS256", keyID, pool.ActiveForSigning, pool.Authorities[0].Algorithm)
}
}

func TestNewJWTPoolSecretRejectsUnsupportedAlgorithm(t *testing.T) {
if _, _, err := newJWTPoolSecret("ate-system", "actor-id-jwt-pool", "HS256", ""); err == nil {
t.Error("newJWTPoolSecret(HS256) returned nil error")
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/envoyproxy/go-control-plane v0.14.0
github.com/envoyproxy/go-control-plane/envoy v1.37.1-0.20260812071801-353463cc7248
github.com/fsnotify/fsnotify v1.9.0
github.com/go-jose/go-jose/v4 v4.1.4
Comment thread
thompsonmax marked this conversation as resolved.
github.com/go-logr/logr v1.4.4
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.21.7
Expand Down Expand Up @@ -137,7 +138,6 @@ require (
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions hack/install-ate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ usage() {
echo " Default: derived from PROJECT_ID/CLUSTER_LOCATION/CLUSTER_NAME"
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 ""
echo "Benchmarks (see benchmarking/README.md for details and customization):"
echo ""
Expand Down
Loading
Loading