diff --git a/go.mod b/go.mod index bfc49f0220b5..f9986947bbc9 100644 --- a/go.mod +++ b/go.mod @@ -192,7 +192,10 @@ require ( kernel.org/pub/linux/libs/security/libcap/cap v1.2.57 ) -require github.com/shirou/gopsutil/v3 v3.21.12 +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/shirou/gopsutil/v3 v3.21.12 +) require ( cloud.google.com/go v0.97.0 // indirect diff --git a/go.sum b/go.sum index 6cb80881488a..d865a4151dd8 100644 --- a/go.sum +++ b/go.sum @@ -778,6 +778,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= diff --git a/x-pack/filebeat/input/internal/dpop/client.go b/x-pack/filebeat/input/internal/dpop/client.go new file mode 100644 index 000000000000..083e817f46bf --- /dev/null +++ b/x-pack/filebeat/input/internal/dpop/client.go @@ -0,0 +1,60 @@ +package dpop + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "errors" + "net/http" + + "golang.org/x/oauth2" +) + +// NewTokenClient builds an *http.Client to be used by oauth2.Config or clientcredentials.Config +// when exchanging code/client_credentials to get an access token. +// This client sends DPoP proofs to the token endpoint. +func NewTokenClient(ctx context.Context, privateKey interface{}, base *http.Client) (*http.Client, error) { + pg, err := NewProofGenerator(privateKey) + if err != nil { + return nil, err + } + tr := &TokenTransport{ProofGen: pg} + if base != nil && base.Transport != nil { + tr.Base = base.Transport + } + client := &http.Client{Transport: tr} + return client, nil +} + +// NewResourceClient builds an *http.Client that wraps oauth2.TokenSource and sends DPoP proofs +// and Authorization: DPoP to protected resource endpoints. +func NewResourceClient(ctx context.Context, privateKey interface{}, ts oauth2.TokenSource, base *http.Client) (*http.Client, error) { + if ts == nil { + return nil, errors.New("token source is required") + } + pg, err := NewProofGenerator(privateKey) + if err != nil { + return nil, err + } + tr := &Transport{TokenSource: ts, ProofGen: pg} + if base != nil && base.Transport != nil { + tr.Base = base.Transport + } + client := &http.Client{Transport: tr} + return client, nil +} + +// GenerateECDSAP256Key creates a fresh ECDSA P-256 private key for DPoP. +func GenerateECDSAP256Key() (*ecdsa.PrivateKey, error) { + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +} + +// GenerateRSAPrivateKey creates a fresh RSA private key suitable for RS256. +func GenerateRSAPrivateKey(bits int) (*rsa.PrivateKey, error) { + if bits <= 0 { + bits = 2048 + } + return rsa.GenerateKey(rand.Reader, bits) +} diff --git a/x-pack/filebeat/input/internal/dpop/jwk.go b/x-pack/filebeat/input/internal/dpop/jwk.go new file mode 100644 index 000000000000..aa1a809cf1a2 --- /dev/null +++ b/x-pack/filebeat/input/internal/dpop/jwk.go @@ -0,0 +1,90 @@ +package dpop + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "errors" + "math/big" +) + +// Helpers to construct minimal public JWKs for DPoP proofs. +// Only the required members are included to keep thumbprints stable. + +// keyAlgorithm enumerates JOSE alg header values we support for DPoP. +type keyAlgorithm string + +const ( + // algES256 is the JOSE alg header for ECDSA P-256/SHA-256. + algES256 keyAlgorithm = "ES256" + // algRS256 is the JOSE alg header for RSASSA-PKCS1-v1_5 with SHA-256. + algRS256 keyAlgorithm = "RS256" +) + +// buildJWKAndAlg constructs a JWK (public key only) and selects the appropriate +// signing algorithm based on the provided private key. Supported keys: +// - *ecdsa.PrivateKey with P-256 (ES256) +// - *rsa.PrivateKey (RS256) +func buildJWKAndAlg(privateKey interface{}) (map[string]interface{}, keyAlgorithm, error) { + switch k := privateKey.(type) { + case *ecdsa.PrivateKey: + return ecPublicJWK(&k.PublicKey) + case *rsa.PrivateKey: + return rsaPublicJWK(&k.PublicKey) + default: + return nil, "", errors.New("unsupported private key type for DPoP: expected *ecdsa.PrivateKey or *rsa.PrivateKey") + } +} + +// ecPublicJWK converts an ECDSA P-256 public key into a minimal public JWK +// and selects ES256 as the signing algorithm. +func ecPublicJWK(pub *ecdsa.PublicKey) (map[string]interface{}, keyAlgorithm, error) { + if pub == nil { + return nil, "", errors.New("nil ECDSA public key") + } + // Only P-256 is supported for ES256 + if pub.Curve != elliptic.P256() { + return nil, "", errors.New("unsupported elliptic curve: only P-256 is supported for DPoP ES256") + } + xBytes := pub.X.Bytes() + yBytes := pub.Y.Bytes() + // Pad to 32 bytes + x := leftPadToSize(xBytes, 32) + y := leftPadToSize(yBytes, 32) + + jwk := map[string]interface{}{ + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(x), + "y": base64.RawURLEncoding.EncodeToString(y), + } + return jwk, algES256, nil +} + +// rsaPublicJWK converts an RSA public key into a minimal public JWK and +// selects RS256 as the signing algorithm. +func rsaPublicJWK(pub *rsa.PublicKey) (map[string]interface{}, keyAlgorithm, error) { + if pub == nil { + return nil, "", errors.New("nil RSA public key") + } + n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()) + jwk := map[string]interface{}{ + "kty": "RSA", + "n": n, + "e": e, + } + return jwk, algRS256, nil +} + +// leftPadToSize returns a slice of length size, left-padding b with zeros +// if necessary. If len(b) >= size, b is returned unchanged. +func leftPadToSize(b []byte, size int) []byte { + if len(b) >= size { + return b + } + p := make([]byte, size) + copy(p[size-len(b):], b) + return p +} diff --git a/x-pack/filebeat/input/internal/dpop/proof.go b/x-pack/filebeat/input/internal/dpop/proof.go new file mode 100644 index 000000000000..7776facd14fa --- /dev/null +++ b/x-pack/filebeat/input/internal/dpop/proof.go @@ -0,0 +1,153 @@ +package dpop + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +// ProofOptions holds optional values like nonce and access token hash (ath). +// 'ath' should be the base64url-encoded SHA256 of the access token bytes. +// If Nonce is provided, it will be set in the DPoP proof as 'nonce'. +// If AccessToken is provided, we will compute the SHA-256 hash and set 'ath'. + +type ProofOptions struct { + Nonce string + AccessToken string +} + +// ProofGenerator builds DPoP proofs for requests. +// It supports ECDSA P-256 and RSA private keys. + +type ProofGenerator struct { + privateKey interface{} + jwk map[string]interface{} + alg keyAlgorithm +} + +// NewProofGenerator creates a new ProofGenerator. +func NewProofGenerator(privateKey interface{}) (*ProofGenerator, error) { + jwk, alg, err := buildJWKAndAlg(privateKey) + if err != nil { + return nil, err + } + return &ProofGenerator{privateKey: privateKey, jwk: jwk, alg: alg}, nil +} + +// BuildProof constructs a signed DPoP proof JWT for the given HTTP method and +// URL. The URL fragment, if present, is stripped per RFC. Optional fields like +// nonce and access token hash (ath) are included when provided via opts. +func (g *ProofGenerator) BuildProof(ctx context.Context, method, url string, opts ProofOptions) (string, error) { + if g == nil || g.privateKey == nil { + return "", errors.New("nil proof generator or key") + } + htu := url + if i := strings.Index(htu, "#"); i >= 0 { // strip fragment + htu = htu[:i] + } + now := time.Now().Unix() + claims := jwt.MapClaims{ + "htu": htu, + "htm": strings.ToUpper(method), + "iat": now, + "jti": randomJTI(), + } + if opts.Nonce != "" { + claims["nonce"] = opts.Nonce + } + if opts.AccessToken != "" { + h, err := sha256Base64URL(opts.AccessToken) + if err != nil { + return "", err + } + claims["ath"] = h + } + + var methodSig jwt.SigningMethod + switch g.alg { + case algES256: + methodSig = jwt.SigningMethodES256 + case algRS256: + methodSig = jwt.SigningMethodRS256 + default: + return "", errors.New("unsupported signing algorithm for DPoP") + } + token := jwt.NewWithClaims(methodSig, claims) + token.Header["typ"] = "dpop+jwt" + token.Header["jwk"] = g.jwk + + signed, err := token.SignedString(g.privateKey) + if err != nil { + return "", err + } + return signed, nil +} + +// randomJTI returns a URL-safe, random identifier for the "jti" claim. +func randomJTI() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// Transport is an http.RoundTripper that adds DPoP proofs and Authorization +// headers (Authorization: DPoP ) to outgoing requests using the +// provided oauth2.TokenSource. It retries once on a DPoP-Nonce challenge. + +type Transport struct { + Base http.RoundTripper + TokenSource oauth2.TokenSource + ProofGen *ProofGenerator +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.Base + if base == nil { + base = http.DefaultTransport + } + if t.TokenSource == nil || t.ProofGen == nil { + return nil, errors.New("dpop transport requires TokenSource and ProofGenerator") + } + tok, err := t.TokenSource.Token() + if err != nil { + return nil, err + } + // clone the request to avoid mutating the original + r := req.Clone(req.Context()) + if tok.AccessToken != "" { + r.Header.Set("Authorization", "DPoP "+tok.AccessToken) + } + proof, err := t.ProofGen.BuildProof(req.Context(), req.Method, req.URL.String(), ProofOptions{AccessToken: tok.AccessToken}) + if err != nil { + return nil, err + } + r.Header.Set("DPoP", proof) + resp, err := base.RoundTrip(r) + if err != nil { + return resp, err + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == 429 { + // Retry once if DPoP-Nonce provided + if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" { + _ = resp.Body.Close() + proof, err = t.ProofGen.BuildProof(req.Context(), req.Method, req.URL.String(), ProofOptions{AccessToken: tok.AccessToken, Nonce: nonce}) + if err != nil { + return nil, err + } + r2 := req.Clone(req.Context()) + if tok.AccessToken != "" { + r2.Header.Set("Authorization", "DPoP "+tok.AccessToken) + } + r2.Header.Set("DPoP", proof) + return base.RoundTrip(r2) + } + } + return resp, nil +} diff --git a/x-pack/filebeat/input/internal/dpop/proof_test.go b/x-pack/filebeat/input/internal/dpop/proof_test.go new file mode 100644 index 000000000000..71b5c8baf31c --- /dev/null +++ b/x-pack/filebeat/input/internal/dpop/proof_test.go @@ -0,0 +1,155 @@ +package dpop + +import ( + "context" + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" +) + +func decodePart(t *testing.T, part string, v interface{}) { + b, err := base64.RawURLEncoding.DecodeString(part) + if err != nil { + t.Fatalf("decode base64: %v", err) + } + if err := json.Unmarshal(b, v); err != nil { + t.Fatalf("unmarshal: %v", err) + } +} + +func TestBuildProofIncludesRequiredClaims(t *testing.T) { + key, err := GenerateECDSAP256Key() + if err != nil { + t.Fatalf("gen key: %v", err) + } + pg, err := NewProofGenerator(key) + if err != nil { + t.Fatalf("proof gen: %v", err) + } + now := time.Now().Unix() + proof, err := pg.BuildProof(context.Background(), http.MethodGet, "https://api.example.com/path?q=1#frag", ProofOptions{}) + if err != nil { + t.Fatalf("build proof: %v", err) + } + parts := strings.Split(proof, ".") + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + var header map[string]interface{} + decodePart(t, parts[0], &header) + if header["typ"] != "dpop+jwt" { + t.Fatalf("wrong typ: %v", header["typ"]) + } + if header["alg"] != "ES256" { + t.Fatalf("wrong alg: %v", header["alg"]) + } + if _, ok := header["jwk"].(map[string]interface{}); !ok { + t.Fatalf("missing jwk") + } + var claims map[string]interface{} + decodePart(t, parts[1], &claims) + if claims["htm"] != "GET" { + t.Fatalf("wrong htm: %v", claims["htm"]) + } + if claims["htu"] != "https://api.example.com/path?q=1" { + t.Fatalf("wrong htu: %v", claims["htu"]) + } + if _, ok := claims["jti"].(string); !ok { + t.Fatalf("missing jti") + } + if iat, ok := claims["iat"].(float64); !ok || int64(iat) < now-5 || int64(iat) > now+5 { + t.Fatalf("iat out of range: %v", claims["iat"]) + } +} + +type staticTokenSource struct{ token *oauth2.Token } + +func (s staticTokenSource) Token() (*oauth2.Token, error) { return s.token, nil } + +func TestResourceTransportSetsHeadersAndAth(t *testing.T) { + key, err := GenerateECDSAP256Key() + if err != nil { + t.Fatalf("gen key: %v", err) + } + accessToken := "test-token" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "DPoP "+accessToken { + w.WriteHeader(400) + return + } + proof := r.Header.Get("DPoP") + if proof == "" { + w.WriteHeader(400) + return + } + parts := strings.Split(proof, ".") + var claims map[string]interface{} + decodePart(t, parts[1], &claims) + if _, ok := claims["ath"].(string); !ok { + w.WriteHeader(400) + return + } + w.WriteHeader(200) + })) + defer srv.Close() + pg, _ := NewProofGenerator(key) + ts := staticTokenSource{token: &oauth2.Token{AccessToken: accessToken, TokenType: "DPoP"}} + cl := &http.Client{Transport: &Transport{TokenSource: ts, ProofGen: pg}} + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/resource", nil) + res, err := cl.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + if res.StatusCode != 200 { + t.Fatalf("unexpected status: %d", res.StatusCode) + } +} + +func TestTokenTransportRetriesWithNonce(t *testing.T) { + key, _ := GenerateECDSAP256Key() + var first = true + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if first { + first = false + w.Header().Set("DPoP-Nonce", "abc123") + w.WriteHeader(401) + return + } + proof := r.Header.Get("DPoP") + parts := strings.Split(proof, ".") + var claims map[string]interface{} + decodePart(t, parts[1], &claims) + if claims["nonce"] != "abc123" { + w.WriteHeader(400) + return + } + w.WriteHeader(200) + })) + defer srv.Close() + pg, _ := NewProofGenerator(key) + cl := &http.Client{Transport: &TokenTransport{ProofGen: pg}} + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/token", nil) + res, err := cl.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + if res.StatusCode != 200 { + t.Fatalf("unexpected status: %d", res.StatusCode) + } +} + +func TestKeyHelpers(t *testing.T) { + if k, err := GenerateECDSAP256Key(); err != nil || k.Curve != elliptic.P256() { + t.Fatalf("ecdsa key: %v", err) + } + if k, err := GenerateRSAPrivateKey(1024); err != nil || k.N.BitLen() < 1024 { + t.Fatalf("rsa key: %v", err) + } +} diff --git a/x-pack/filebeat/input/internal/dpop/signature.go b/x-pack/filebeat/input/internal/dpop/signature.go new file mode 100644 index 000000000000..5b96f2cf0405 --- /dev/null +++ b/x-pack/filebeat/input/internal/dpop/signature.go @@ -0,0 +1,25 @@ +package dpop + +import ( + "crypto/sha256" + "encoding/base64" +) + +// Helpers to hash and sign DPoP proof inputs. + +// sha256Base64URL returns the base64url (no padding) encoding of the SHA-256 +// digest of the provided string. +func sha256Base64URL(data string) (string, error) { + h := sha256.Sum256([]byte(data)) + enc := base64RawURLEncode(h[:]) + return enc, nil +} + +// base64RawURLEncode encodes bytes using base64url without padding. +func base64RawURLEncode(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +// signECDSA creates an ES256 signature over data (with SHA-256) and returns +// the raw (r || s) concatenation, as required by JOSE for ES256. +// Signing is delegated to the jwt/v5 library in proof.go diff --git a/x-pack/filebeat/input/internal/dpop/token_transport.go b/x-pack/filebeat/input/internal/dpop/token_transport.go new file mode 100644 index 000000000000..14be48405252 --- /dev/null +++ b/x-pack/filebeat/input/internal/dpop/token_transport.go @@ -0,0 +1,51 @@ +package dpop + +import ( + "errors" + "net/http" +) + +// TokenTransport adds a DPoP proof to token endpoint HTTP requests. +// It retries once on DPoP-Nonce challenges (401/400/429 with DPoP-Nonce header). +// This transport should be installed on the http.Client used by oauth2 when fetching tokens. + +type TokenTransport struct { + Base http.RoundTripper + ProofGen *ProofGenerator +} + +// RoundTrip implements http.RoundTripper, injecting a DPoP proof into token +// endpoint requests and handling one retry on a nonce challenge. +func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.Base + if base == nil { + base = http.DefaultTransport + } + if t.ProofGen == nil { + return nil, errors.New("token dpop transport requires ProofGenerator") + } + + r := req.Clone(req.Context()) + proof, err := t.ProofGen.BuildProof(req.Context(), req.Method, req.URL.String(), ProofOptions{}) + if err != nil { + return nil, err + } + r.Header.Set("DPoP", proof) + resp, err := base.RoundTrip(r) + if err != nil { + return resp, err + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == 429 { + if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" { + _ = resp.Body.Close() + proof, err = t.ProofGen.BuildProof(req.Context(), req.Method, req.URL.String(), ProofOptions{Nonce: nonce}) + if err != nil { + return nil, err + } + r2 := req.Clone(req.Context()) + r2.Header.Set("DPoP", proof) + return base.RoundTrip(r2) + } + } + return resp, nil +}