From 40e8d65bab18e3b505fbc709ba522dacc778b70d Mon Sep 17 00:00:00 2001 From: Taahir Ahmed Date: Fri, 28 Aug 2026 22:00:19 -0700 Subject: [PATCH] localjwtauthority: Prep for rotation support * Add a RefreshingPool abstraction like localca.RefreshingPool. This provides caching (so we don't read the keys fresh from the filesystem) for each signing operation. * Extend ConcretePool so that it tracks which key is currently active for signing, instead of always picking the first key. A followup PR will add admin commands for actually rotating the JWT pool. --- cmd/ate-setup/internal/steps/create.go | 5 +- .../internal/actoridentity/actoridentity.go | 34 +-- .../actoridentity/actoridentity_test.go | 54 +++- cmd/ateapi/internal/actoridjwt/actoridjwt.go | 170 ----------- cmd/ateapi/main.go | 10 +- .../internal/cmd/admin_make_jwt_pool.go | 5 +- internal/actoridjwt/actoridjwt.go | 85 ++++++ internal/localca/localca.go | 10 +- internal/localca/localca_test.go | 2 +- .../localjwtauthority/localjwtauthority.go | 275 ++++++++++++++++-- .../localjwtauthority_test.go | 97 ++++-- 11 files changed, 467 insertions(+), 280 deletions(-) delete mode 100644 cmd/ateapi/internal/actoridjwt/actoridjwt.go create mode 100644 internal/actoridjwt/actoridjwt.go diff --git a/cmd/ate-setup/internal/steps/create.go b/cmd/ate-setup/internal/steps/create.go index 9add907961..0c9c9574b0 100644 --- a/cmd/ate-setup/internal/steps/create.go +++ b/cmd/ate-setup/internal/steps/create.go @@ -255,8 +255,9 @@ func (e *Env) createJWTPool(ctx context.Context, namespace, name string) error { if err != nil { return fmt.Errorf("while generating the JWT authority for %s/%s: %w", namespace, name, err) } - poolBytes, err := localjwtauthority.Marshal(&localjwtauthority.Pool{ - Authorities: []*localjwtauthority.Authority{authority}, + poolBytes, err := localjwtauthority.Marshal(&localjwtauthority.ConcretePool{ + Authorities: []*localjwtauthority.Authority{authority}, + ActiveForSigning: poolKeyID, }) if err != nil { return fmt.Errorf("while marshaling the JWT pool for %s/%s: %w", namespace, name, err) diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 7304c43d15..f337dedcf7 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -23,14 +23,13 @@ import ( "fmt" "log/slog" "net/url" - "os" "path" "time" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/controlapi" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache" + "github.com/agent-substrate/substrate/internal/actoridjwt" "github.com/agent-substrate/substrate/internal/localca" "github.com/agent-substrate/substrate/internal/localjwtauthority" "github.com/agent-substrate/substrate/internal/principal" @@ -49,11 +48,12 @@ import ( type Server struct { ateapipb.UnimplementedActorIdentityServer + // TODO(identity): Issuer is probably logically a property of the JWT + // signing pool. actorIdentityJWTIssuer string - // TODO: Cache the signing keys in memory, so we don't read from a file every time. - actorIDJWTPoolFile string - actorIDCAPool localca.Pool + actorIDJWTPool localjwtauthority.Pool + actorIDCAPool localca.Pool // store is the actor database. MintCert consults it to confirm the caller // is entitled to the actor it is asking for a credential for. @@ -63,10 +63,10 @@ type Server struct { var _ ateapipb.ActorIdentityServer = (*Server)(nil) -func New(actorIdentityJWTIssuer, actorIDJWTPoolFile string, actorIDCAPool localca.Pool, store store.Interface, workers *workercache.Cache) *Server { +func New(actorIdentityJWTIssuer string, actorIDJWTPool localjwtauthority.Pool, actorIDCAPool localca.Pool, store store.Interface, workers *workercache.Cache) *Server { return &Server{ actorIdentityJWTIssuer: actorIdentityJWTIssuer, - actorIDJWTPoolFile: actorIDJWTPoolFile, + actorIDJWTPool: actorIDJWTPool, actorIDCAPool: actorIDCAPool, store: store, workers: workers, @@ -102,15 +102,9 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at // TODO: Cross-check the verified caller and requested actor against the actor database. - // TODO: Cache signing keys in memory, so we don't read from disk every time. - signingPoolBytes, err := os.ReadFile(s.actorIDJWTPoolFile) - if err != nil { - return nil, fmt.Errorf("while reading signing pool bytes: %w", err) - } - - signingPool, err := localjwtauthority.Unmarshal(signingPoolBytes) - if err != nil { - return nil, fmt.Errorf("while unmarshaling signing pool: %w", err) + // We only issue tokens with audience bindings. + if len(req.GetAudience()) == 0 { + return nil, fmt.Errorf("at least one audience must be requested") } actorClaims := &actoridjwt.Claims{ @@ -131,13 +125,7 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at }, } - actorWireClaims, err := actoridjwt.ClaimsToWire(actorClaims) - if err != nil { - return nil, fmt.Errorf("while making actor JWT claims: %w", err) - } - - // Assume the first authority is the one to use for signing. - actorJWT, err := actoridjwt.Sign(actorWireClaims, signingPool.Authorities[0].SigningKey, signingPool.Authorities[0].Algorithm, signingPool.Authorities[0].ID) + actorJWT, err := s.actorIDJWTPool.SignJWT(actorClaims) if err != nil { return nil, fmt.Errorf("while signing actor JWT: %w", err) } diff --git a/cmd/ateapi/internal/actoridentity/actoridentity_test.go b/cmd/ateapi/internal/actoridentity/actoridentity_test.go index d1282de67f..e4b9db28c9 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity_test.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity_test.go @@ -33,6 +33,7 @@ import ( "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest" "github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache" "github.com/agent-substrate/substrate/internal/localca" + "github.com/agent-substrate/substrate/internal/localjwtauthority" "github.com/agent-substrate/substrate/internal/principal" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/substratex509" @@ -147,15 +148,24 @@ func ctxWithCert(cert *x509.Certificate) context.Context { func newTestServer(t *testing.T, st store.Interface) *Server { t.Helper() - ca, err := localca.GenerateCA("test-actor-ca", localca.KeyTypeED25519, 24*time.Hour) + certificateAuthority, err := localca.GenerateCA("test-actor-ca", localca.KeyTypeED25519, 24*time.Hour) if err != nil { t.Fatalf("generate CA: %v", err) } - pool := &localca.ConcretePool{ - CAs: []*localca.CA{ca}, + certificateAuthorityPool := &localca.ConcretePool{ + CAs: []*localca.CA{certificateAuthority}, ActiveForSigning: "test-actor-ca", } + jwtAuthority, err := localjwtauthority.GenerateECDSAP256Authority("1") + if err != nil { + t.Fatalf("while generating JWT authority: %v", err) + } + jwtAuthorityPool := &localjwtauthority.ConcretePool{ + Authorities: []*localjwtauthority.Authority{jwtAuthority}, + ActiveForSigning: "1", + } + var workers *workercache.Cache if st != nil { workers = workercache.New(st, time.Hour) @@ -165,7 +175,7 @@ func newTestServer(t *testing.T, st store.Interface) *Server { t.Fatalf("start worker cache: %v", err) } } - return New("issuer", "", pool, st, workers) + return New("issuer", jwtAuthorityPool, certificateAuthorityPool, st, workers) } // staleWatchStore wraps a store with a WatchWorkers that never delivers, @@ -319,12 +329,25 @@ func TestMintCertReadsThroughWorkerCacheMiss(t *testing.T) { func newTestServerWithCache(t *testing.T, st store.Interface, workers *workercache.Cache) *Server { t.Helper() - ca, err := localca.GenerateCA("test-actor-ca", localca.KeyTypeED25519, 24*time.Hour) + certificateAuthority, err := localca.GenerateCA("test-actor-ca", localca.KeyTypeED25519, 24*time.Hour) if err != nil { t.Fatalf("generate CA: %v", err) } - pool := &localca.ConcretePool{CAs: []*localca.CA{ca}} - return New("issuer", "", pool, st, workers) + certificateAuthorityPool := &localca.ConcretePool{ + CAs: []*localca.CA{certificateAuthority}, + ActiveForSigning: "test-actor-ca", + } + + jwtAuthority, err := localjwtauthority.GenerateECDSAP256Authority("1") + if err != nil { + t.Fatalf("while generating JWT authority: %v", err) + } + jwtAuthorityPool := &localjwtauthority.ConcretePool{ + Authorities: []*localjwtauthority.Authority{jwtAuthority}, + ActiveForSigning: "1", + } + + return New("issuer", jwtAuthorityPool, certificateAuthorityPool, st, workers) } func TestMintJWTRequiresConfiguredJWTProvider(t *testing.T) { @@ -909,16 +932,25 @@ func TestMintCertAuthorizesBeforeSigning(t *testing.T) { t.Fatal(err) } - ca, err := localca.GenerateCA("test-actor-ca", localca.KeyTypeED25519, 24*time.Hour) + certificateAuthority, err := localca.GenerateCA("test-actor-ca", localca.KeyTypeED25519, 24*time.Hour) if err != nil { t.Fatalf("generate CA: %v", err) } - pool := &localca.ConcretePool{ - CAs: []*localca.CA{ca}, + certificateAuthorityPool := &localca.ConcretePool{ + CAs: []*localca.CA{certificateAuthority}, ActiveForSigning: "test-actor-ca", } - srv := New("issuer", "", pool, st, workers) + jwtAuthority, err := localjwtauthority.GenerateECDSAP256Authority("1") + if err != nil { + t.Fatalf("while generating JWT authority: %v", err) + } + jwtAuthorityPool := &localjwtauthority.ConcretePool{ + Authorities: []*localjwtauthority.Authority{jwtAuthority}, + ActiveForSigning: "1", + } + + srv := New("issuer", jwtAuthorityPool, certificateAuthorityPool, st, workers) actor, err := st.GetActor(ctx, resources.ActorRef{Atespace: testAtespace, Name: testActorName}) if err != nil { diff --git a/cmd/ateapi/internal/actoridjwt/actoridjwt.go b/cmd/ateapi/internal/actoridjwt/actoridjwt.go deleted file mode 100644 index 9e8b44b31b..0000000000 --- a/cmd/ateapi/internal/actoridjwt/actoridjwt.go +++ /dev/null @@ -1,170 +0,0 @@ -// 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 actoridjwt - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "fmt" - "hash" - "time" -) - -type Claims struct { - // Claims from RFC7519 - Issuer string - Subject string - Audiences []string - Expiration time.Time - NotBefore time.Time - IssuedAt time.Time - JTI string - - // Claims from ADK's session model - Substrate SubstrateClaims -} - -type SubstrateClaims struct { - Atespace string - ActorName string - ActorUID string -} - -type wireHeader struct { - Type string `json:"typ,omitempty"` - Algorithm string `json:"alg,omitempty"` - KeyID string `json:"kid,omitempty"` -} - -type WireClaims struct { - // Claims from RFC7519 - Issuer string `json:"iss,omitempty"` - Subject string `json:"sub,omitempty"` - Audiences json.RawMessage `json:"aud,omitempty"` - Expiration float64 `json:"exp,omitempty"` - NotBefore float64 `json:"nbf,omitempty"` - IssuedAt float64 `json:"iat,omitempty"` - JTI string `json:"jti,omitempty"` - - // Claims from ADK's session model. - Substrate WireSubstrateClaims `json:"ate.dev,omitempty"` -} - -type WireSubstrateClaims struct { - Atespace string `json:"atespace,omitempty"` - ActorName string `json:"actorName,omitempty"` - ActorUID string `json:"actorUID,omitempty"` -} - -func ClaimsToWire(claims *Claims) (*WireClaims, error) { - rawAudiences, err := json.Marshal(claims.Audiences) - if err != nil { - return nil, fmt.Errorf("while marshaling audience: %w", err) - } - - wire := &WireClaims{ - Issuer: claims.Issuer, - Subject: claims.Subject, - Audiences: rawAudiences, - Expiration: float64(claims.Expiration.Unix()), - NotBefore: float64(claims.NotBefore.Unix()), - IssuedAt: float64(claims.IssuedAt.Unix()), - JTI: claims.JTI, - Substrate: WireSubstrateClaims{ - Atespace: claims.Substrate.Atespace, - ActorName: claims.Substrate.ActorName, - ActorUID: claims.Substrate.ActorUID, - }, - } - - return wire, nil -} - -// Sign -func Sign(wireClaims *WireClaims, signingKey crypto.PrivateKey, algorithm, keyID string) (string, error) { - payloadBytes, err := json.Marshal(wireClaims) - if err != nil { - return "", fmt.Errorf("while marshaling payload: %w", err) - } - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) - - rawHeader := wireHeader{ - Algorithm: algorithm, - KeyID: keyID, - } - headerBytes, err := json.Marshal(rawHeader) - if err != nil { - return "", fmt.Errorf("while marshaling header: %w", err) - } - headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes) - - toBeSigned := headerB64 + "." + payloadB64 - - var sigBytes []byte - switch algorithm { - case "RS256": - rsaKey := signingKey.(*rsa.PrivateKey) - toBeSignedDigest := hashBytes(crypto.SHA256.New(), []byte(toBeSigned)) - sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, toBeSignedDigest) - if err != nil { - return "", fmt.Errorf("while performing RSA PKCS1v15 signature: %w", err) - } - case "RS384": - rsaKey := signingKey.(*rsa.PrivateKey) - toBeSignedDigest := hashBytes(crypto.SHA384.New(), []byte(toBeSigned)) - sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA384, toBeSignedDigest) - if err != nil { - return "", fmt.Errorf("while performing RSA PKCS1v15 signature: %w", err) - } - case "RS512": - rsaKey := signingKey.(*rsa.PrivateKey) - toBeSignedDigest := hashBytes(crypto.SHA512.New(), []byte(toBeSigned)) - sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA512, toBeSignedDigest) - if err != nil { - return "", fmt.Errorf("while performing RSA PKCS1v15 signature: %w", err) - } - case "ES256": - // JOSE ES256 defined at https://datatracker.ietf.org/doc/rfc7518/ section 3.4 - ecdsaKey := signingKey.(*ecdsa.PrivateKey) - if ecdsaKey.Curve != elliptic.P256() { - return "", fmt.Errorf("ES256 requires a P256 key") - } - toBeSignedDigest := hashBytes(crypto.SHA256.New(), []byte(toBeSigned)) - r, s, err := ecdsa.Sign(rand.Reader, ecdsaKey, toBeSignedDigest) - if err != nil { - return "", fmt.Errorf("while performing ecdsa signature: %w", err) - } - sigBytes = make([]byte, 2*32) - r.FillBytes(sigBytes[:32]) - s.FillBytes(sigBytes[32:]) - default: - return "", fmt.Errorf("unimplemented algorithm %q", algorithm) - } - - sigB64 := base64.RawURLEncoding.EncodeToString(sigBytes) - - return toBeSigned + "." + sigB64, nil -} - -func hashBytes(hasher hash.Hash, bytes []byte) []byte { - hasher.Write(bytes) - hash := hasher.Sum(nil) - return hash[:] -} diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index a59fb3589f..8b75072b46 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -36,6 +36,7 @@ import ( "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/credbundle" "github.com/agent-substrate/substrate/internal/localca" + "github.com/agent-substrate/substrate/internal/localjwtauthority" "github.com/agent-substrate/substrate/internal/serverboot" "github.com/agent-substrate/substrate/internal/version" "github.com/agent-substrate/substrate/internal/volume" @@ -193,10 +194,15 @@ func main() { actorIDCAPool, err := localca.NewRefreshingPool(*actorIDCAPoolFile) if err != nil { - serverboot.Fatal(ctx, "while loading the Actor ID CA", err) + serverboot.Fatal(ctx, "while loading the Actor ID certificate authority pool: %w", err) } - actorIdentitySrv := actoridentity.New(actorIdentityJWTIssuer, *actorIDJWTPoolFile, actorIDCAPool, persistence, workerCache) + actorIDJWTAuthorityPool, err := localjwtauthority.NewRefreshingPool(*actorIDJWTPoolFile) + if err != nil { + serverboot.Fatal(ctx, "while loading the Actor ID JWT authority pool: %w", err) + } + + actorIdentitySrv := actoridentity.New(actorIdentityJWTIssuer, actorIDJWTAuthorityPool, actorIDCAPool, persistence, workerCache) lisCfg := &net.ListenConfig{} lis, err := lisCfg.Listen(ctx, "tcp", *listenAddr) diff --git a/cmd/kubectl-ate/internal/cmd/admin_make_jwt_pool.go b/cmd/kubectl-ate/internal/cmd/admin_make_jwt_pool.go index 659c3ca37b..bba94fa095 100644 --- a/cmd/kubectl-ate/internal/cmd/admin_make_jwt_pool.go +++ b/cmd/kubectl-ate/internal/cmd/admin_make_jwt_pool.go @@ -48,8 +48,9 @@ var makeJwtPoolCmd = &cobra.Command{ return fmt.Errorf("while generating JWT authority: %w", err) } - pool := &localjwtauthority.Pool{ - Authorities: []*localjwtauthority.Authority{authority}, + pool := &localjwtauthority.ConcretePool{ + Authorities: []*localjwtauthority.Authority{authority}, + ActiveForSigning: keyID, } poolBytes, err := localjwtauthority.Marshal(pool) diff --git a/internal/actoridjwt/actoridjwt.go b/internal/actoridjwt/actoridjwt.go new file mode 100644 index 0000000000..4a40b95974 --- /dev/null +++ b/internal/actoridjwt/actoridjwt.go @@ -0,0 +1,85 @@ +// 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 actoridjwt + +import ( + "encoding/json" + "fmt" + "time" +) + +type Claims struct { + // Claims from RFC7519 + Issuer string + Subject string + Audiences []string + Expiration time.Time + NotBefore time.Time + IssuedAt time.Time + JTI string + + // Claims from ADK's session model + Substrate SubstrateClaims +} + +type SubstrateClaims struct { + Atespace string + ActorName string + ActorUID string +} + +type WireClaims struct { + // Claims from RFC7519 + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audiences json.RawMessage `json:"aud,omitempty"` + Expiration float64 `json:"exp,omitempty"` + NotBefore float64 `json:"nbf,omitempty"` + IssuedAt float64 `json:"iat,omitempty"` + JTI string `json:"jti,omitempty"` + + // Claims from ADK's session model. + Substrate WireSubstrateClaims `json:"ate.dev,omitempty"` +} + +type WireSubstrateClaims struct { + Atespace string `json:"atespace,omitempty"` + ActorName string `json:"actorName,omitempty"` + ActorUID string `json:"actorUID,omitempty"` +} + +func ClaimsToWire(claims *Claims) (*WireClaims, error) { + rawAudiences, err := json.Marshal(claims.Audiences) + if err != nil { + return nil, fmt.Errorf("while marshaling audience: %w", err) + } + + wire := &WireClaims{ + Issuer: claims.Issuer, + Subject: claims.Subject, + Audiences: rawAudiences, + Expiration: float64(claims.Expiration.Unix()), + NotBefore: float64(claims.NotBefore.Unix()), + IssuedAt: float64(claims.IssuedAt.Unix()), + JTI: claims.JTI, + Substrate: WireSubstrateClaims{ + Atespace: claims.Substrate.Atespace, + ActorName: claims.Substrate.ActorName, + ActorUID: claims.Substrate.ActorUID, + }, + } + + return wire, nil +} diff --git a/internal/localca/localca.go b/internal/localca/localca.go index 760411743c..3b6272ee97 100644 --- a/internal/localca/localca.go +++ b/internal/localca/localca.go @@ -44,8 +44,6 @@ import ( "os" "sync" "time" - - "k8s.io/utils/clock" ) // Pool is the interface for a CA pool. @@ -82,7 +80,6 @@ type Pool interface { // components to restart. type RefreshingPool struct { stateFile string - clock clock.PassiveClock // lock covers nextLoad and pool lock sync.Mutex @@ -95,8 +92,9 @@ var _ Pool = (*RefreshingPool)(nil) func NewRefreshingPool(stateFile string) (*RefreshingPool, error) { rp := &RefreshingPool{ stateFile: stateFile, - clock: clock.RealClock{}, } + rp.lock.Lock() + defer rp.lock.Unlock() if err := rp.refreshIfNecessary(); err != nil { return nil, fmt.Errorf("while loading pool: %w", err) } @@ -105,7 +103,7 @@ func NewRefreshingPool(stateFile string) (*RefreshingPool, error) { // refreshIfNecessary must be called while p.lock is held. func (p *RefreshingPool) refreshIfNecessary() error { - if p.pool != nil && p.clock.Now().Before(p.nextLoad) { + if p.pool != nil && time.Now().Before(p.nextLoad) { return nil } @@ -120,7 +118,7 @@ func (p *RefreshingPool) refreshIfNecessary() error { } p.pool = pool - p.nextLoad = p.clock.Now().Add(time.Minute) + p.nextLoad = time.Now().Add(time.Minute) return nil } diff --git a/internal/localca/localca_test.go b/internal/localca/localca_test.go index 67665e2173..3dd1aed99d 100644 --- a/internal/localca/localca_test.go +++ b/internal/localca/localca_test.go @@ -164,7 +164,7 @@ func TestRefreshingPool(t *testing.T) { t.Fatalf("Unexpected error marshaling pool 1: %v", err) } - ca2, err := GenerateCA("1", KeyTypeED25519, 365*24*time.Hour) + ca2, err := GenerateCA("2", KeyTypeED25519, 365*24*time.Hour) if err != nil { t.Fatalf("Unexpected error generating CA 2: %v", err) } diff --git a/internal/localjwtauthority/localjwtauthority.go b/internal/localjwtauthority/localjwtauthority.go index 97021fb2dc..8a3d2a9332 100644 --- a/internal/localjwtauthority/localjwtauthority.go +++ b/internal/localjwtauthority/localjwtauthority.go @@ -20,37 +20,266 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/json" - "encoding/pem" "fmt" + "hash" + "os" + "sync" + "time" + + "github.com/agent-substrate/substrate/internal/actoridjwt" ) -type Pool struct { +// Pool is the interface for a JWT signing pool. +// +// Logically, a Pool is a collection of multiple authorities. One or more are +// designated as active for signing. The rest are inactive, but are still +// trusted for verifying JWTs. +// +// The active/inactive desngination allows a Pool to be seamlessly rotated. +// +// Normally, we let callers define their own compatibility interfaces. But in +// most cases you'll want to either use a RefreshingPool (for controllers and +// servers), or a ConcretePool (for CLIs and tests). +type Pool interface { + // SignJWT signs a JWT with the given claims. + SignJWT(*actoridjwt.Claims) (string, error) + + // VerificationKeys returns the verification key set of this pool, for + // exporting via OpenID Connect Discovery. + VerificationKeys() ([]*VerificationKey, error) +} + +type VerificationKey struct { + KeyID string + PublicKey crypto.PublicKey +} + +// RefreshingPool is a wrapper around ConcretePool that periodically reloads the +// state from disk. This allows JWT signing and verification to continue +// working seamlessly even as an administrator rotates the pool, without +// requiring any components to restart. +type RefreshingPool struct { + stateFile string + + // lock covers nextLoad and pool + lock sync.Mutex + nextLoad time.Time + pool *ConcretePool +} + +var _ Pool = (*RefreshingPool)(nil) + +func NewRefreshingPool(stateFile string) (*RefreshingPool, error) { + rp := &RefreshingPool{ + stateFile: stateFile, + } + rp.lock.Lock() + defer rp.lock.Unlock() + if err := rp.refreshIfNecessary(); err != nil { + return nil, fmt.Errorf("while loading pool: %w", err) + } + return rp, nil +} + +// refreshIfNecessary must be called under p.lock +func (p *RefreshingPool) refreshIfNecessary() error { + if p.pool != nil && time.Now().Before(p.nextLoad) { + return nil + } + + poolBytes, err := os.ReadFile(p.stateFile) + if err != nil { + return fmt.Errorf("while reading pool state: %w", err) + } + + pool, err := Unmarshal(poolBytes) + if err != nil { + return fmt.Errorf("while unmarshaling pool: %w", err) + } + + p.pool = pool + p.nextLoad = time.Now().Add(time.Minute) + + return nil +} + +func (p *RefreshingPool) SignJWT(claims *actoridjwt.Claims) (string, error) { + p.lock.Lock() + defer p.lock.Unlock() + if err := p.refreshIfNecessary(); err != nil { + return "", fmt.Errorf("while refreshing pool: %w", err) + } + return p.pool.SignJWT(claims) +} + +func (p *RefreshingPool) VerificationKeys() ([]*VerificationKey, error) { + p.lock.Lock() + defer p.lock.Unlock() + if err := p.refreshIfNecessary(); err != nil { + return nil, fmt.Errorf("while refreshing pool: %w", err) + } + return p.pool.VerificationKeys() +} + +type ConcretePool struct { Authorities []*Authority + // Which authority is active for signing? + ActiveForSigning string +} + +var _ Pool = (*ConcretePool)(nil) + +func (p *ConcretePool) SignJWT(claims *actoridjwt.Claims) (string, error) { + wireClaims, err := actoridjwt.ClaimsToWire(claims) + if err != nil { + return "", fmt.Errorf("while converting claims to wire model: %w", err) + } + + payloadBytes, err := json.Marshal(wireClaims) + if err != nil { + return "", fmt.Errorf("while marshaling payload: %w", err) + } + + // TODO(ahmedtd): Select authority + var selectedAuthority *Authority + if p.ActiveForSigning != "" { + for _, authority := range p.Authorities { + if authority.ID == p.ActiveForSigning { + selectedAuthority = authority + } + } + if selectedAuthority == nil { + return "", fmt.Errorf("selected authority %q not present", p.ActiveForSigning) + } + } else { + // Fall back to first entry. + if len(p.Authorities) == 0 { + return "", fmt.Errorf("pool has no authorities defined") + } + selectedAuthority = p.Authorities[0] + } + + // TODO(identity): The key IDs should probably be SHA256 of the key, to + // prevent user misuse. + jwt, err := sign(payloadBytes, selectedAuthority.SigningKey, selectedAuthority.Algorithm, selectedAuthority.ID) + if err != nil { + return "", fmt.Errorf("while signing JWT: %w", err) + } + + return jwt, nil +} + +func (p *ConcretePool) VerificationKeys() ([]*VerificationKey, error) { + var keys []*VerificationKey + for _, authority := range p.Authorities { + vk := &VerificationKey{ + KeyID: authority.ID, + PublicKey: authority.SigningKey.Public(), + } + keys = append(keys, vk) + } + return keys, nil +} + +type wireHeader struct { + Type string `json:"typ,omitempty"` + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` +} + +func sign(payloadBytes []byte, signingKey crypto.PrivateKey, algorithm, keyID string) (string, error) { + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + + rawHeader := wireHeader{ + Algorithm: algorithm, + KeyID: keyID, + } + headerBytes, err := json.Marshal(rawHeader) + if err != nil { + return "", fmt.Errorf("while marshaling header: %w", err) + } + headerB64 := base64.RawURLEncoding.EncodeToString(headerBytes) + + toBeSigned := headerB64 + "." + payloadB64 + + var sigBytes []byte + switch algorithm { + case "RS256": + rsaKey := signingKey.(*rsa.PrivateKey) + toBeSignedDigest := hashBytes(crypto.SHA256.New(), []byte(toBeSigned)) + sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, toBeSignedDigest) + if err != nil { + return "", fmt.Errorf("while performing RSA PKCS1v15 signature: %w", err) + } + case "RS384": + rsaKey := signingKey.(*rsa.PrivateKey) + toBeSignedDigest := hashBytes(crypto.SHA384.New(), []byte(toBeSigned)) + sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA384, toBeSignedDigest) + if err != nil { + return "", fmt.Errorf("while performing RSA PKCS1v15 signature: %w", err) + } + case "RS512": + rsaKey := signingKey.(*rsa.PrivateKey) + toBeSignedDigest := hashBytes(crypto.SHA512.New(), []byte(toBeSigned)) + sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA512, toBeSignedDigest) + if err != nil { + return "", fmt.Errorf("while performing RSA PKCS1v15 signature: %w", err) + } + case "ES256": + // JOSE ES256 defined at https://datatracker.ietf.org/doc/rfc7518/ section 3.4 + ecdsaKey := signingKey.(*ecdsa.PrivateKey) + if ecdsaKey.Curve != elliptic.P256() { + return "", fmt.Errorf("ES256 requires a P256 key") + } + toBeSignedDigest := hashBytes(crypto.SHA256.New(), []byte(toBeSigned)) + r, s, err := ecdsa.Sign(rand.Reader, ecdsaKey, toBeSignedDigest) + if err != nil { + return "", fmt.Errorf("while performing ecdsa signature: %w", err) + } + sigBytes = make([]byte, 2*32) + r.FillBytes(sigBytes[:32]) + s.FillBytes(sigBytes[32:]) + default: + return "", fmt.Errorf("unimplemented algorithm %q", algorithm) + } + + sigB64 := base64.RawURLEncoding.EncodeToString(sigBytes) + + return toBeSigned + "." + sigB64, nil +} + +func hashBytes(hasher hash.Hash, bytes []byte) []byte { + hasher.Write(bytes) + hash := hasher.Sum(nil) + return hash[:] } type Authority struct { ID string Algorithm string - SigningKey crypto.PrivateKey + SigningKey crypto.Signer } type serializedPool struct { - Authorities []*serializedAuthority + Authorities []*serializedAuthority + ActiveForSigning string } type serializedAuthority struct { ID string Algorithm string SigningKeyPKCS8 []byte - SigningKeyPEM string } // Marshal serializes a Pool to JSON. -func Marshal(pool *Pool) ([]byte, error) { +func Marshal(pool *ConcretePool) ([]byte, error) { wire := &serializedPool{} + wire.ActiveForSigning = pool.ActiveForSigning for _, authority := range pool.Authorities { authorityWire := &serializedAuthority{} authorityWire.ID = authority.ID @@ -74,25 +303,29 @@ func Marshal(pool *Pool) ([]byte, error) { } // Unmarshal loads a Pool from JSON. -func Unmarshal(wireBytes []byte) (*Pool, error) { +func Unmarshal(wireBytes []byte) (*ConcretePool, error) { wire := &serializedPool{} if err := json.Unmarshal(wireBytes, wire); err != nil { return nil, fmt.Errorf("while unmarshaling JSON: %w", err) } - pool := &Pool{} + pool := &ConcretePool{ + ActiveForSigning: wire.ActiveForSigning, + } for _, wireAuthority := range wire.Authorities { authority := &Authority{ ID: wireAuthority.ID, Algorithm: wireAuthority.Algorithm, } - signingKey, err := parsePrivateKey(wireAuthority.SigningKeyPKCS8, wireAuthority.SigningKeyPEM) + key, err := x509.ParsePKCS8PrivateKey(wireAuthority.SigningKeyPKCS8) if err != nil { return nil, fmt.Errorf("while parsing signing key: %w", err) } - authority.SigningKey = signingKey + + // All key types from ParsePKCS8PrivateKey implement Signer + authority.SigningKey = key.(crypto.Signer) pool.Authorities = append(pool.Authorities, authority) } @@ -100,28 +333,6 @@ func Unmarshal(wireBytes []byte) (*Pool, error) { return pool, nil } -func parsePrivateKey(pkcs8 []byte, pemData string) (crypto.PrivateKey, error) { - if len(pkcs8) != 0 { - return x509.ParsePKCS8PrivateKey(pkcs8) - } - - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, fmt.Errorf("missing PEM block") - } - - if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return key, nil - } - return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type) -} - // GenerateECDSAP256Authority generates an ECDSA P256 JWT signing key. func GenerateECDSAP256Authority(id string) (*Authority, error) { privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) diff --git a/internal/localjwtauthority/localjwtauthority_test.go b/internal/localjwtauthority/localjwtauthority_test.go index 7f25a72ea6..a3b2e02ce7 100644 --- a/internal/localjwtauthority/localjwtauthority_test.go +++ b/internal/localjwtauthority/localjwtauthority_test.go @@ -15,48 +15,83 @@ package localjwtauthority import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "encoding/json" - "encoding/pem" + "os" + "path/filepath" "testing" + "testing/synctest" + "time" + + "github.com/google/go-cmp/cmp" ) -func TestUnmarshalPEMSigningKey(t *testing.T) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +func TestRefreshingPool(t *testing.T) { + ca1, err := GenerateECDSAP256Authority("1") if err != nil { - t.Fatalf("GenerateKey(): %v", err) + t.Fatalf("Unexpected error generating CA 1: %v", err) } - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - t.Fatalf("MarshalECPrivateKey(): %v", err) + pool1 := &ConcretePool{ + Authorities: []*Authority{ca1}, + ActiveForSigning: "1", } - keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) - - data, err := json.Marshal(&serializedPool{ - Authorities: []*serializedAuthority{{ - ID: "1", - Algorithm: "ES256", - SigningKeyPEM: keyPEM, - }}, - }) + pool1Bytes, err := Marshal(pool1) if err != nil { - t.Fatalf("Marshal(): %v", err) + t.Fatalf("Unexpected error marshaling pool 1: %v", err) } - pool, err := Unmarshal(data) + ca2, err := GenerateECDSAP256Authority("2") if err != nil { - t.Fatalf("Unmarshal(): %v", err) + t.Fatalf("Unexpected error generating CA 2: %v", err) } - if len(pool.Authorities) != 1 { - t.Fatalf("Authorities length = %d, want 1", len(pool.Authorities)) + pool2 := &ConcretePool{ + Authorities: []*Authority{ca2}, + ActiveForSigning: "2", } - if pool.Authorities[0].Algorithm != "ES256" { - t.Fatalf("Algorithm = %q, want ES256", pool.Authorities[0].Algorithm) - } - if _, ok := pool.Authorities[0].SigningKey.(*ecdsa.PrivateKey); !ok { - t.Fatalf("SigningKey type = %T, want *ecdsa.PrivateKey", pool.Authorities[0].SigningKey) + pool2Bytes, err := Marshal(pool2) + if err != nil { + t.Fatalf("Unexpected error marshaling pool 2: %v", err) } + + synctest.Test(t, func(t *testing.T) { + tempDir := t.TempDir() + poolFile := filepath.Join(tempDir, "pool.json") + + if err := os.WriteFile(poolFile, pool1Bytes, 0o600); err != nil { + t.Fatalf("Unexpected error writing pool 1: %v", err) + } + + refreshingPool, err := NewRefreshingPool(poolFile) + if err != nil { + t.Fatalf("Unexpected error creating refreshing pool: %v", err) + } + + gotVerificationKeys, err := refreshingPool.VerificationKeys() + if err != nil { + t.Fatalf("Unexpected errors getting anchors from refreshing pool: %v", err) + } + wantVerificationKeys, err := pool1.VerificationKeys() + if err != nil { + t.Fatalf("Unexpected errors getting anchors from pool 1: %v", err) + } + if diff := cmp.Diff(gotVerificationKeys, wantVerificationKeys); diff != "" { + t.Fatalf("Refreshing pool returned wrong trust anchors; diff (-got +want)\n%s", diff) + } + + // Write pool2 and advance past the cache threshold. + if err := os.WriteFile(poolFile, pool2Bytes, 0o600); err != nil { + t.Fatalf("Unexpected error writing pool 2: %v", err) + } + time.Sleep(61 * time.Second) + + gotVerificationKeys, err = refreshingPool.VerificationKeys() + if err != nil { + t.Fatalf("Unexpected errors getting anchors from refreshing pool: %v", err) + } + wantVerificationKeys, err = pool2.VerificationKeys() + if err != nil { + t.Fatalf("Unexpected errors getting anchors from pool 2: %v", err) + } + if diff := cmp.Diff(gotVerificationKeys, wantVerificationKeys); diff != "" { + t.Fatalf("Refreshing pool returned wrong trust anchors after file update; diff (-got +want)\n%s", diff) + } + }) }