From 068153b604cecfccb829f2e93566f60f190bffcc Mon Sep 17 00:00:00 2001 From: Konstantins Feofantovs Date: Mon, 10 Aug 2026 19:43:08 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20nodeauth=20=E2=80=94=20local=20projecte?= =?UTF-8?q?d-token=20verification=20against=20cluster=20JWKS=20(ADR=200010?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller-side library that verifies the projected ServiceAccount tokens the node role will present (ADR 0010). Verification is local: parse the cluster JWKS (RSA and EC keys), then check each token's signature, expiry, audience, and subject with golang-jwt/jwt/v5 — no TokenReview, so no new RBAC verb. - ParseJWKS + KeySet: JWK→crypto key conversion, kid indexing. - HTTPKeySource: OIDC discovery (follows jwks_uri) over a caller-supplied client; the controller will pass the API server's in-cluster REST transport. - CachingKeySource: caches the key set, refetches once on an unknown kid (signing-key rotation). - Verifier: asymmetric-only method allow-list (defeats HS/none algorithm confusion), expiration required, audience match, optional expected-subject pin to the node role's ServiceAccount. Unit tests mint RSA and EC tokens and cover the happy path plus wrong audience, expired, missing expiry, tampered signature, unknown kid, wrong subject, HS256 confusion, and the none algorithm; JWKS tests cover discovery, caching, and rotation. golang-jwt/jwt/v5 is pure-Go with no transitive dependencies. No wiring yet; the receiver and node client land in the next slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 1 + go.sum | 2 + internal/nodeauth/jwks.go | 266 ++++++++++++++++++++++++++ internal/nodeauth/jwks_test.go | 155 +++++++++++++++ internal/nodeauth/verifier.go | 129 +++++++++++++ internal/nodeauth/verifier_test.go | 291 +++++++++++++++++++++++++++++ 6 files changed, 844 insertions(+) create mode 100644 internal/nodeauth/jwks.go create mode 100644 internal/nodeauth/jwks_test.go create mode 100644 internal/nodeauth/verifier.go create mode 100644 internal/nodeauth/verifier_test.go diff --git a/go.mod b/go.mod index c82b2cd..c91c8b1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/RebuildStackCo/runtime-agent go 1.26.1 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.70.1 k8s.io/api v0.36.3 diff --git a/go.sum b/go.sum index 949cb22..1d40226 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/nodeauth/jwks.go b/internal/nodeauth/jwks.go new file mode 100644 index 0000000..f154ec7 --- /dev/null +++ b/internal/nodeauth/jwks.go @@ -0,0 +1,266 @@ +// Package nodeauth verifies the projected ServiceAccount tokens the node role +// presents to the controller (ADR 0010). Verification is local: the controller +// fetches the cluster's JWKS from the OIDC discovery endpoints and checks each +// token's signature, expiry, audience, and subject itself — no TokenReview, so +// no new RBAC verb. The package holds no Kubernetes client; callers pass an +// *http.Client (in-cluster, the controller supplies the API server's REST +// transport) and the issuer base URL. +package nodeauth + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sync" +) + +// KeySet is an immutable set of signing keys indexed by key ID, parsed from a +// JWKS document. A token's "kid" header selects the key that must verify it. +type KeySet struct { + keys map[string]crypto.PublicKey +} + +// Key returns the public key for kid. When the token carries no kid and the +// set holds exactly one key, that key is returned — the unambiguous case some +// issuers produce. Otherwise a missing kid is an error, never a silent guess. +func (ks *KeySet) Key(kid string) (crypto.PublicKey, error) { + if kid == "" { + if len(ks.keys) == 1 { + for _, k := range ks.keys { + return k, nil + } + } + return nil, fmt.Errorf("nodeauth: token has no kid and the key set is not singular") + } + k, ok := ks.keys[kid] + if !ok { + return nil, fmt.Errorf("nodeauth: no signing key for kid %q", kid) + } + return k, nil +} + +// jwk is one key in a JWKS document; only the fields we verify with are read. +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Crv string `json:"crv"` + N string `json:"n"` + E string `json:"e"` + X string `json:"x"` + Y string `json:"y"` +} + +// ParseJWKS decodes a JWKS document into a KeySet. It understands the two key +// types the Kubernetes service-account issuer uses: RSA (RS256/384/512) and +// EC (ES256/384/512). Unknown key types are skipped rather than failing the +// whole set — a set may legitimately carry a type we do not verify with — but +// an empty result is an error, since it would verify nothing. +func ParseJWKS(data []byte) (*KeySet, error) { + var doc struct { + Keys []jwk `json:"keys"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("nodeauth: decoding JWKS: %w", err) + } + keys := make(map[string]crypto.PublicKey) + for i, k := range doc.Keys { + pub, err := k.publicKey() + if err != nil { + return nil, fmt.Errorf("nodeauth: key %d (kid %q): %w", i, k.Kid, err) + } + if pub == nil { + continue // a key type we do not verify with; skip it + } + keys[k.Kid] = pub + } + if len(keys) == 0 { + return nil, fmt.Errorf("nodeauth: JWKS contained no usable RSA or EC keys") + } + return &KeySet{keys: keys}, nil +} + +// publicKey converts one JWK to a crypto public key, or (nil, nil) for a key +// type this package does not verify with. +func (k jwk) publicKey() (crypto.PublicKey, error) { + switch k.Kty { + case "RSA": + n, err := decodeBigInt(k.N) + if err != nil { + return nil, fmt.Errorf("modulus: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(k.E) + if err != nil { + return nil, fmt.Errorf("exponent: %w", err) + } + if len(eBytes) == 0 { + return nil, fmt.Errorf("empty exponent") + } + return &rsa.PublicKey{N: n, E: int(new(big.Int).SetBytes(eBytes).Int64())}, nil + case "EC": + curve, err := curveFor(k.Crv) + if err != nil { + return nil, err + } + x, err := decodeBigInt(k.X) + if err != nil { + return nil, fmt.Errorf("x coordinate: %w", err) + } + y, err := decodeBigInt(k.Y) + if err != nil { + return nil, fmt.Errorf("y coordinate: %w", err) + } + // An off-curve point cannot verify any signature (ECDSA verification + // rejects it), so no explicit on-curve check is needed here — that + // check matters for ECDH key agreement, which this package never does. + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + default: + return nil, nil + } +} + +func curveFor(crv string) (elliptic.Curve, error) { + switch crv { + case "P-256": + return elliptic.P256(), nil + case "P-384": + return elliptic.P384(), nil + case "P-521": + return elliptic.P521(), nil + default: + return nil, fmt.Errorf("unsupported EC curve %q", crv) + } +} + +func decodeBigInt(b64 string) (*big.Int, error) { + raw, err := base64.RawURLEncoding.DecodeString(b64) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, fmt.Errorf("empty value") + } + return new(big.Int).SetBytes(raw), nil +} + +// KeySource fetches the current JWKS. The controller's implementation reads it +// from the API server over the in-cluster REST transport; tests serve it from +// an httptest server. +type KeySource interface { + Fetch(ctx context.Context) (*KeySet, error) +} + +// HTTPKeySource resolves the cluster's OIDC discovery document to its jwks_uri +// and fetches the key set, using the caller-supplied HTTP client. Passing the +// API server's REST transport is what carries the in-cluster CA and bearer +// credential; this package adds no transport policy of its own. +type HTTPKeySource struct { + // IssuerBaseURL is where discovery lives: /.well-known/openid-configuration. + // For in-cluster validation this is the API server host. + IssuerBaseURL string + Client *http.Client +} + +// Fetch performs OIDC discovery and returns the parsed key set. It follows the +// jwks_uri the discovery document advertises — the OIDC-correct source of the +// signing keys — rather than assuming a fixed path. +func (s *HTTPKeySource) Fetch(ctx context.Context) (*KeySet, error) { + client := s.Client + if client == nil { + client = http.DefaultClient + } + var discovery struct { + JWKSURI string `json:"jwks_uri"` + } + if err := getJSON(ctx, client, s.IssuerBaseURL+"/.well-known/openid-configuration", &discovery); err != nil { + return nil, fmt.Errorf("nodeauth: OIDC discovery: %w", err) + } + if discovery.JWKSURI == "" { + return nil, fmt.Errorf("nodeauth: discovery document has no jwks_uri") + } + raw, err := getBytes(ctx, client, discovery.JWKSURI) + if err != nil { + return nil, fmt.Errorf("nodeauth: fetching JWKS: %w", err) + } + return ParseJWKS(raw) +} + +func getBytes(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode) + } + return body, nil +} + +func getJSON(ctx context.Context, client *http.Client, url string, out any) error { + body, err := getBytes(ctx, client, url) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +// KeyProvider supplies the verifying key for a token's kid, refreshing from the +// source when a kid is unknown — a signing-key rotation on the cluster. +type KeyProvider interface { + Key(ctx context.Context, kid string) (crypto.PublicKey, error) +} + +// StaticKeys is a KeyProvider backed by a fixed KeySet; it never refreshes. +// Useful in tests and where the key set is supplied out of band. +type StaticKeys struct{ Set *KeySet } + +// Key returns the key for kid from the fixed set, ignoring the context. +func (s StaticKeys) Key(_ context.Context, kid string) (crypto.PublicKey, error) { + return s.Set.Key(kid) +} + +// CachingKeySource is a KeyProvider that caches the fetched KeySet and refetches +// once when asked for a kid it does not hold — the signal that the cluster +// rotated its signing keys. Concurrent verifications share one refresh. +type CachingKeySource struct { + Source KeySource + + mu sync.Mutex + cached *KeySet +} + +// Key returns the key for kid, fetching the set on first use and refetching +// once if the cached set lacks the kid. A refetch that still lacks the kid is +// an error — the token was signed by a key the cluster does not advertise. +func (c *CachingKeySource) Key(ctx context.Context, kid string) (crypto.PublicKey, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cached != nil { + if key, err := c.cached.Key(kid); err == nil { + return key, nil + } + } + set, err := c.Source.Fetch(ctx) + if err != nil { + return nil, err + } + c.cached = set + return set.Key(kid) +} diff --git a/internal/nodeauth/jwks_test.go b/internal/nodeauth/jwks_test.go new file mode 100644 index 0000000..3d70f80 --- /dev/null +++ b/internal/nodeauth/jwks_test.go @@ -0,0 +1,155 @@ +package nodeauth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestParseJWKSRejectsEmptySet(t *testing.T) { + if _, err := ParseJWKS([]byte(`{"keys":[]}`)); err == nil { + t.Fatal("empty JWKS was accepted; it verifies nothing") + } +} + +func TestParseJWKSSkipsUnknownKeyType(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + // One usable RSA key alongside an oct (symmetric) entry we cannot verify + // with: the oct is skipped, the RSA key survives. + var rsaDoc struct { + Keys []json.RawMessage `json:"keys"` + } + if err := json.Unmarshal(rsaJWKS("rsa-1", &key.PublicKey), &rsaDoc); err != nil { + t.Fatal(err) + } + mixed := fmt.Sprintf(`{"keys":[%s,{"kty":"oct","kid":"sym","k":"AAAA"}]}`, string(rsaDoc.Keys[0])) + + set, err := ParseJWKS([]byte(mixed)) + if err != nil { + t.Fatalf("mixed JWKS rejected: %v", err) + } + if _, err := set.Key("rsa-1"); err != nil { + t.Errorf("usable RSA key missing after skipping oct: %v", err) + } + if _, err := set.Key("sym"); err == nil { + t.Error("symmetric key should not have been added to the set") + } +} + +func TestKeySetKeySingularNoKid(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + set, err := ParseJWKS(rsaJWKS("rsa-1", &key.PublicKey)) + if err != nil { + t.Fatal(err) + } + if _, err := set.Key(""); err != nil { + t.Errorf("singular key set should resolve an empty kid: %v", err) + } +} + +// jwksTestServer serves OIDC discovery and JWKS, counting JWKS hits so tests +// can assert on refresh behavior. The JWKS body is swappable to model a +// signing-key rotation. +type jwksTestServer struct { + *httptest.Server + jwksHits atomic.Int32 + body atomic.Pointer[[]byte] +} + +func newJWKSTestServer(t *testing.T, initial []byte) *jwksTestServer { + t.Helper() + s := &jwksTestServer{} + s.body.Store(&initial) + mux := http.NewServeMux() + s.Server = httptest.NewServer(mux) + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"issuer": s.URL, "jwks_uri": s.URL + "/openid/v1/jwks"}) + }) + mux.HandleFunc("/openid/v1/jwks", func(w http.ResponseWriter, _ *http.Request) { + s.jwksHits.Add(1) + _, _ = w.Write(*s.body.Load()) + }) + t.Cleanup(s.Close) + return s +} + +func TestHTTPKeySourceFollowsDiscovery(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + srv := newJWKSTestServer(t, rsaJWKS("rsa-1", &key.PublicKey)) + + src := &HTTPKeySource{IssuerBaseURL: srv.URL, Client: srv.Client()} + set, err := src.Fetch(context.Background()) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if _, err := set.Key("rsa-1"); err != nil { + t.Errorf("fetched set missing rsa-1: %v", err) + } +} + +func TestCachingKeySourceCachesThenRefreshesOnUnknownKid(t *testing.T) { + key1, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + srv := newJWKSTestServer(t, rsaJWKS("rsa-1", &key1.PublicKey)) + c := &CachingKeySource{Source: &HTTPKeySource{IssuerBaseURL: srv.URL, Client: srv.Client()}} + ctx := context.Background() + + if _, err := c.Key(ctx, "rsa-1"); err != nil { + t.Fatalf("first lookup: %v", err) + } + if _, err := c.Key(ctx, "rsa-1"); err != nil { + t.Fatalf("second lookup: %v", err) + } + if got := srv.jwksHits.Load(); got != 1 { + t.Fatalf("JWKS fetched %d times for a cached kid, want 1", got) + } + + // Rotate the cluster's signing key: a new kid appears. The next lookup for + // the unknown kid must trigger exactly one refetch and then resolve. + key2, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + rotated := rsaJWKS("rsa-2", &key2.PublicKey) + srv.body.Store(&rotated) + + if _, err := c.Key(ctx, "rsa-2"); err != nil { + t.Fatalf("lookup after rotation: %v", err) + } + if got := srv.jwksHits.Load(); got != 2 { + t.Fatalf("JWKS fetched %d times total, want 2 (one refresh on unknown kid)", got) + } +} + +func TestCachingKeySourceUnknownKidAfterRefreshErrors(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + srv := newJWKSTestServer(t, rsaJWKS("rsa-1", &key.PublicKey)) + c := &CachingKeySource{Source: &HTTPKeySource{IssuerBaseURL: srv.URL, Client: srv.Client()}} + + if _, err := c.Key(context.Background(), "does-not-exist"); err == nil { + t.Fatal("a kid absent even after refresh should error") + } + if got := srv.jwksHits.Load(); got != 1 { + t.Fatalf("JWKS fetched %d times, want 1 (a single refresh attempt)", got) + } +} diff --git a/internal/nodeauth/verifier.go b/internal/nodeauth/verifier.go new file mode 100644 index 0000000..f420ad3 --- /dev/null +++ b/internal/nodeauth/verifier.go @@ -0,0 +1,129 @@ +package nodeauth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// asymmetricMethods is the closed set of signing algorithms the verifier +// accepts. Restricting to asymmetric algorithms is the defense against the +// algorithm-confusion attack: without it, a token forged with alg=HS256 would +// be verified using the RSA/EC *public* key as an HMAC secret — public data an +// attacker also holds. "none" is likewise never accepted. +var asymmetricMethods = []string{ + "RS256", "RS384", "RS512", + "ES256", "ES384", "ES512", + "PS256", "PS384", "PS512", +} + +// Identity is what a verified token establishes about its bearer. +type Identity struct { + // Subject is the raw "sub" claim, e.g. + // "system:serviceaccount:runtime-agent:runtime-agent-node". + Subject string + // Namespace and ServiceAccount are parsed from Subject when it has the + // canonical service-account form; empty otherwise. + Namespace string + ServiceAccount string + // Audience is the token's audience list, already checked to contain the + // verifier's expected audience. + Audience []string + // ExpiresAt is the token's expiry. + ExpiresAt time.Time +} + +// Verifier validates a projected ServiceAccount token against the cluster +// signing keys and the controller's expectations. It is safe for concurrent +// use; the key provider owns any refresh. +type Verifier struct { + keys KeyProvider + audience string + expectedSubject string + leeway time.Duration +} + +// Option configures a Verifier. +type Option func(*Verifier) + +// WithLeeway tolerates a clock skew of d when checking exp/nbf. Kubernetes +// keeps node and control-plane clocks close; a small leeway absorbs the rest. +func WithLeeway(d time.Duration) Option { + return func(v *Verifier) { v.leeway = d } +} + +// WithExpectedSubject requires the token's subject to equal sub — the node +// role's ServiceAccount (e.g. +// "system:serviceaccount:runtime-agent:runtime-agent-node"). With it, only the +// node DaemonSet's identity is accepted, not any token the cluster happens to +// have minted for the controller's audience. An empty value disables the check. +func WithExpectedSubject(sub string) Option { + return func(v *Verifier) { v.expectedSubject = sub } +} + +// NewVerifier builds a verifier that accepts tokens whose audience contains +// audience and whose signature validates against a key from keys. +func NewVerifier(keys KeyProvider, audience string, opts ...Option) *Verifier { + v := &Verifier{keys: keys, audience: audience, leeway: time.Minute} + for _, opt := range opts { + opt(v) + } + return v +} + +// Verify validates tokenString and returns the established identity. It checks, +// in one pass: the signature (against the kid-selected key, asymmetric methods +// only), that expiry is present and unexpired, that the audience contains the +// configured value, and — when configured — that the subject is the expected +// ServiceAccount. Any failure returns an error and a zero Identity; nothing is +// trusted from an unverified token. +func (v *Verifier) Verify(ctx context.Context, tokenString string) (Identity, error) { + keyfunc := func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + return v.keys.Key(ctx, kid) + } + + var claims jwt.RegisteredClaims + _, err := jwt.ParseWithClaims(tokenString, &claims, keyfunc, + jwt.WithValidMethods(asymmetricMethods), + jwt.WithExpirationRequired(), + jwt.WithAudience(v.audience), + jwt.WithLeeway(v.leeway), + ) + if err != nil { + return Identity{}, fmt.Errorf("nodeauth: verifying token: %w", err) + } + + if v.expectedSubject != "" && claims.Subject != v.expectedSubject { + return Identity{}, fmt.Errorf("nodeauth: token subject %q is not the expected %q", claims.Subject, v.expectedSubject) + } + + id := Identity{ + Subject: claims.Subject, + Audience: []string(claims.Audience), + } + if claims.ExpiresAt != nil { + id.ExpiresAt = claims.ExpiresAt.Time + } + id.Namespace, id.ServiceAccount = splitServiceAccountSubject(claims.Subject) + return id, nil +} + +// splitServiceAccountSubject parses "system:serviceaccount::" into +// its namespace and name. A subject not in that form yields two empty strings — +// the caller keeps the raw Subject regardless. +func splitServiceAccountSubject(sub string) (namespace, name string) { + const prefix = "system:serviceaccount:" + if !strings.HasPrefix(sub, prefix) { + return "", "" + } + rest := strings.TrimPrefix(sub, prefix) + ns, sa, ok := strings.Cut(rest, ":") + if !ok { + return "", "" + } + return ns, sa +} diff --git a/internal/nodeauth/verifier_test.go b/internal/nodeauth/verifier_test.go new file mode 100644 index 0000000..f70c93a --- /dev/null +++ b/internal/nodeauth/verifier_test.go @@ -0,0 +1,291 @@ +package nodeauth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + testAudience = "rebuildstack-controller" + nodeSubject = "system:serviceaccount:runtime-agent:runtime-agent-node" +) + +// signer bundles a signing key with the kid and method used to mint tokens for +// it, plus the KeySet a verifier would build from its public half. +type signer struct { + kid string + method jwt.SigningMethod + priv any + set *KeySet +} + +func newRSASigner(t *testing.T, kid string) signer { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return signer{kid: kid, method: jwt.SigningMethodRS256, priv: key, set: keySetFromJWKS(t, rsaJWKS(kid, &key.PublicKey))} +} + +func newECSigner(t *testing.T, kid string) signer { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return signer{kid: kid, method: jwt.SigningMethodES256, priv: key, set: keySetFromJWKS(t, ecJWKS(kid, &key.PublicKey))} +} + +// mint signs a token with the signer's key, allowing the claims to be tweaked +// per test. The kid header is set so the verifier selects the matching key. +func (s signer) mint(t *testing.T, claims jwt.RegisteredClaims) string { + t.Helper() + tok := jwt.NewWithClaims(s.method, claims) + tok.Header["kid"] = s.kid + signed, err := tok.SignedString(s.priv) + if err != nil { + t.Fatalf("signing token: %v", err) + } + return signed +} + +// validClaims is a well-formed token body: node subject, controller audience, +// unexpired. +func validClaims() jwt.RegisteredClaims { + now := time.Now() + return jwt.RegisteredClaims{ + Subject: nodeSubject, + Audience: jwt.ClaimStrings{testAudience}, + IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), + } +} + +func newTestVerifier(s signer) *Verifier { + return NewVerifier(StaticKeys{Set: s.set}, testAudience, WithExpectedSubject(nodeSubject)) +} + +func TestVerifyValidRSAToken(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + id, err := v.Verify(context.Background(), s.mint(t, validClaims())) + if err != nil { + t.Fatalf("valid token rejected: %v", err) + } + if id.Subject != nodeSubject { + t.Errorf("subject = %q, want %q", id.Subject, nodeSubject) + } + if id.Namespace != "runtime-agent" || id.ServiceAccount != "runtime-agent-node" { + t.Errorf("parsed identity = %q/%q, want runtime-agent/runtime-agent-node", id.Namespace, id.ServiceAccount) + } + if len(id.Audience) != 1 || id.Audience[0] != testAudience { + t.Errorf("audience = %v, want [%s]", id.Audience, testAudience) + } +} + +func TestVerifyValidECToken(t *testing.T) { + s := newECSigner(t, "ec-1") + v := newTestVerifier(s) + + if _, err := v.Verify(context.Background(), s.mint(t, validClaims())); err != nil { + t.Fatalf("valid EC token rejected: %v", err) + } +} + +func TestVerifyRejectsWrongAudience(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + claims := validClaims() + claims.Audience = jwt.ClaimStrings{"some-other-service"} + if _, err := v.Verify(context.Background(), s.mint(t, claims)); err == nil { + t.Fatal("token for a different audience was accepted") + } +} + +func TestVerifyRejectsExpired(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + claims := validClaims() + now := time.Now() + claims.IssuedAt = jwt.NewNumericDate(now.Add(-2 * time.Hour)) + claims.ExpiresAt = jwt.NewNumericDate(now.Add(-time.Hour)) // well past any leeway + if _, err := v.Verify(context.Background(), s.mint(t, claims)); err == nil { + t.Fatal("expired token was accepted") + } +} + +func TestVerifyRejectsMissingExpiry(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + claims := validClaims() + claims.ExpiresAt = nil + if _, err := v.Verify(context.Background(), s.mint(t, claims)); err == nil { + t.Fatal("token without an expiry was accepted") + } +} + +func TestVerifyRejectsTamperedSignature(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + token := s.mint(t, validClaims()) + // Flip the first character of the signature segment. The last base64url + // character of an RSA-2048 signature encodes only padding bits, so mutate + // the first one, whose high-order bits are always significant. + lastDot := strings.LastIndexByte(token, '.') + b := []byte(token) + i := lastDot + 1 + if b[i] == 'A' { + b[i] = 'B' + } else { + b[i] = 'A' + } + if _, err := v.Verify(context.Background(), string(b)); err == nil { + t.Fatal("token with a tampered signature was accepted") + } +} + +func TestVerifyRejectsUnknownKid(t *testing.T) { + signed := newRSASigner(t, "rsa-1") + other := newRSASigner(t, "rsa-2") + // Verifier only knows `other`'s keys, but the token is signed by `signed` + // and carries kid "rsa-1", which the key set does not hold. + v := newTestVerifier(other) + if _, err := v.Verify(context.Background(), signed.mint(t, validClaims())); err == nil { + t.Fatal("token signed by an unknown key was accepted") + } +} + +func TestVerifyRejectsWrongSubject(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + claims := validClaims() + claims.Subject = "system:serviceaccount:runtime-agent:some-other-sa" + if _, err := v.Verify(context.Background(), s.mint(t, claims)); err == nil { + t.Fatal("token from an unexpected subject was accepted") + } +} + +// TestVerifyRejectsAlgorithmConfusion is the critical negative: a token forged +// with HS256, using the RSA public key as the HMAC secret, must be rejected by +// the method allow-list before the public key is ever used as a symmetric key. +func TestVerifyRejectsAlgorithmConfusion(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + pubDER, err := x509.MarshalPKIXPublicKey(s.priv.(*rsa.PrivateKey).Public()) + if err != nil { + t.Fatal(err) + } + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, validClaims()) + forged.Header["kid"] = s.kid + signed, err := forged.SignedString(pubDER) + if err != nil { + t.Fatalf("signing HS256 forgery: %v", err) + } + if _, err := v.Verify(context.Background(), signed); err == nil { + t.Fatal("HS256 algorithm-confusion forgery was accepted") + } +} + +func TestVerifyRejectsNoneAlgorithm(t *testing.T) { + s := newRSASigner(t, "rsa-1") + v := newTestVerifier(s) + + tok := jwt.NewWithClaims(jwt.SigningMethodNone, validClaims()) + tok.Header["kid"] = s.kid + signed, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("signing none-alg token: %v", err) + } + if _, err := v.Verify(context.Background(), signed); err == nil { + t.Fatal("none-algorithm token was accepted") + } +} + +func TestVerifyWithoutSubjectCheckAcceptsAnySubject(t *testing.T) { + s := newRSASigner(t, "rsa-1") + // No WithExpectedSubject: audience is the only identity gate. + v := NewVerifier(StaticKeys{Set: s.set}, testAudience) + + claims := validClaims() + claims.Subject = "system:serviceaccount:other-ns:other-sa" + id, err := v.Verify(context.Background(), s.mint(t, claims)) + if err != nil { + t.Fatalf("token rejected with subject check disabled: %v", err) + } + if id.Namespace != "other-ns" || id.ServiceAccount != "other-sa" { + t.Errorf("parsed identity = %q/%q, want other-ns/other-sa", id.Namespace, id.ServiceAccount) + } +} + +// --- JWKS building helpers ------------------------------------------------- + +func rsaJWKS(kid string, pub *rsa.PublicKey) []byte { + e := big.NewInt(int64(pub.E)).Bytes() + return jwksBytes(map[string]string{ + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(e), + }) +} + +func ecJWKS(kid string, pub *ecdsa.PublicKey) []byte { + // Derive the affine coordinates via the uncompressed ECDH encoding + // (0x04 || x || y) instead of the deprecated raw big.Int fields. + ecdhPub, err := pub.ECDH() + if err != nil { + panic(err) + } + raw := ecdhPub.Bytes() + coord := (len(raw) - 1) / 2 // strip the 0x04 prefix, split the two halves + x, y := raw[1:1+coord], raw[1+coord:] + return jwksBytes(map[string]string{ + "kty": "EC", + "kid": kid, + "use": "sig", + "alg": "ES256", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(x), + "y": base64.RawURLEncoding.EncodeToString(y), + }) +} + +func jwksBytes(keys ...map[string]string) []byte { + doc := map[string]any{"keys": keys} + raw, err := json.Marshal(doc) + if err != nil { + panic(err) + } + return raw +} + +func keySetFromJWKS(t *testing.T, raw []byte) *KeySet { + t.Helper() + set, err := ParseJWKS(raw) + if err != nil { + t.Fatalf("parsing test JWKS: %v", err) + } + return set +}