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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
266 changes: 266 additions & 0 deletions internal/nodeauth/jwks.go
Original file line number Diff line number Diff line change
@@ -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: <base>/.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)
}
Loading