From 95a5e11f397e088d561a64167c171d50b885820b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Oct 2025 04:22:51 +0000 Subject: [PATCH 1/4] feat: Add DPoP OAuth2 client and transport Implement DPoP support for OAuth2 clients. Co-authored-by: robert.kortschak --- libbeat/common/dpopoauth2/client.go | 60 +++++++ libbeat/common/dpopoauth2/jwk.go | 82 +++++++++ libbeat/common/dpopoauth2/proof.go | 169 +++++++++++++++++++ libbeat/common/dpopoauth2/proof_test.go | 155 +++++++++++++++++ libbeat/common/dpopoauth2/signature.go | 44 +++++ libbeat/common/dpopoauth2/token_transport.go | 49 ++++++ 6 files changed, 559 insertions(+) create mode 100644 libbeat/common/dpopoauth2/client.go create mode 100644 libbeat/common/dpopoauth2/jwk.go create mode 100644 libbeat/common/dpopoauth2/proof.go create mode 100644 libbeat/common/dpopoauth2/proof_test.go create mode 100644 libbeat/common/dpopoauth2/signature.go create mode 100644 libbeat/common/dpopoauth2/token_transport.go diff --git a/libbeat/common/dpopoauth2/client.go b/libbeat/common/dpopoauth2/client.go new file mode 100644 index 000000000000..dc272b13c215 --- /dev/null +++ b/libbeat/common/dpopoauth2/client.go @@ -0,0 +1,60 @@ +package dpopoauth2 + +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/libbeat/common/dpopoauth2/jwk.go b/libbeat/common/dpopoauth2/jwk.go new file mode 100644 index 000000000000..f7e9eb1fb4ba --- /dev/null +++ b/libbeat/common/dpopoauth2/jwk.go @@ -0,0 +1,82 @@ +package dpopoauth2 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "errors" + "math/big" +) + +// jwkPublic represents a minimal JSON Web Key (public part) used in DPoP headers. +// We only include the required members per RFCs for thumbprint stability. +// Use map[string]interface{} when attaching to JOSE header. + +type keyAlgorithm string + +const ( + algES256 keyAlgorithm = "ES256" + 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") + } +} + +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 +} + +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 +} + +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/libbeat/common/dpopoauth2/proof.go b/libbeat/common/dpopoauth2/proof.go new file mode 100644 index 000000000000..840726dc8320 --- /dev/null +++ b/libbeat/common/dpopoauth2/proof.go @@ -0,0 +1,169 @@ +package dpopoauth2 + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "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 produces a compact JWS string containing the DPoP proof for the given HTTP method and URL. +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] + } + header := map[string]interface{}{ + "typ": "dpop+jwt", + "alg": string(g.alg), + "jwk": g.jwk, + } + now := time.Now().Unix() + claims := map[string]interface{}{ + "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 + } + return signJWS(g.privateKey, header, claims) +} + +func randomJTI() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func signJWS(privateKey interface{}, header, claims map[string]interface{}) (string, error) { + hb, err := json.Marshal(header) + if err != nil { + return "", err + } + cb, err := json.Marshal(claims) + if err != nil { + return "", err + } + encHeader := base64.RawURLEncoding.EncodeToString(hb) + encPayload := base64.RawURLEncoding.EncodeToString(cb) + unsigned := encHeader + "." + encPayload + + sig, err := signDetached(privateKey, unsigned) + if err != nil { + return "", err + } + return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +func signDetached(privateKey interface{}, data string) ([]byte, error) { + switch k := privateKey.(type) { + case *ecdsa.PrivateKey: + return signECDSA(k, []byte(data)) + case *rsa.PrivateKey: + return signRSA(k, []byte(data)) + default: + return nil, errors.New("unsupported private key type for signing") + } +} + +// Transport decorates an underlying RoundTripper to add DPoP proofs and bearer auth. +// It uses the provided TokenSource for access tokens and adds both Authorization and DPoP headers. + +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/libbeat/common/dpopoauth2/proof_test.go b/libbeat/common/dpopoauth2/proof_test.go new file mode 100644 index 000000000000..df27ac7bc6ff --- /dev/null +++ b/libbeat/common/dpopoauth2/proof_test.go @@ -0,0 +1,155 @@ +package dpopoauth2 + +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/libbeat/common/dpopoauth2/signature.go b/libbeat/common/dpopoauth2/signature.go new file mode 100644 index 000000000000..d924c9ea14e6 --- /dev/null +++ b/libbeat/common/dpopoauth2/signature.go @@ -0,0 +1,44 @@ +package dpopoauth2 + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "errors" +) + +func sha256Base64URL(data string) (string, error) { + h := sha256.Sum256([]byte(data)) + enc := base64RawURLEncode(h[:]) + return enc, nil +} + +func base64RawURLEncode(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func signECDSA(priv *ecdsa.PrivateKey, data []byte) ([]byte, error) { + h := sha256.Sum256(data) + r, s, err := ecdsa.Sign(rand.Reader, priv, h[:]) + if err != nil { + return nil, err + } + // DPoP ES256 requires the raw concatenation of r and s (not DER) + curveBits := priv.Curve.Params().BitSize + if priv.Curve != elliptic.P256() { + return nil, errors.New("unsupported ECDSA curve for ES256") + } + octLen := (curveBits + 7) / 8 + rb := leftPadToSize(r.Bytes(), octLen) + sb := leftPadToSize(s.Bytes(), octLen) + return append(rb, sb...), nil +} + +func signRSA(priv *rsa.PrivateKey, data []byte) ([]byte, error) { + h := sha256.Sum256(data) + return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) +} diff --git a/libbeat/common/dpopoauth2/token_transport.go b/libbeat/common/dpopoauth2/token_transport.go new file mode 100644 index 000000000000..a92fafb85520 --- /dev/null +++ b/libbeat/common/dpopoauth2/token_transport.go @@ -0,0 +1,49 @@ +package dpopoauth2 + +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 +} + +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 +} From 267a1b51dab1a5c49a3d914b55df254031c5c9e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Oct 2025 04:29:03 +0000 Subject: [PATCH 2/4] Move DPoP OAuth2 client code to filebeat input Co-authored-by: robert.kortschak --- .../filebeat/input/internal/dpop}/client.go | 2 +- .../dpopoauth2 => x-pack/filebeat/input/internal/dpop}/jwk.go | 2 +- .../dpopoauth2 => x-pack/filebeat/input/internal/dpop}/proof.go | 2 +- .../filebeat/input/internal/dpop}/proof_test.go | 2 +- .../filebeat/input/internal/dpop}/signature.go | 2 +- .../filebeat/input/internal/dpop}/token_transport.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename {libbeat/common/dpopoauth2 => x-pack/filebeat/input/internal/dpop}/client.go (98%) rename {libbeat/common/dpopoauth2 => x-pack/filebeat/input/internal/dpop}/jwk.go (99%) rename {libbeat/common/dpopoauth2 => x-pack/filebeat/input/internal/dpop}/proof.go (99%) rename {libbeat/common/dpopoauth2 => x-pack/filebeat/input/internal/dpop}/proof_test.go (99%) rename {libbeat/common/dpopoauth2 => x-pack/filebeat/input/internal/dpop}/signature.go (98%) rename {libbeat/common/dpopoauth2 => x-pack/filebeat/input/internal/dpop}/token_transport.go (98%) diff --git a/libbeat/common/dpopoauth2/client.go b/x-pack/filebeat/input/internal/dpop/client.go similarity index 98% rename from libbeat/common/dpopoauth2/client.go rename to x-pack/filebeat/input/internal/dpop/client.go index dc272b13c215..a31c4ca0aad9 100644 --- a/libbeat/common/dpopoauth2/client.go +++ b/x-pack/filebeat/input/internal/dpop/client.go @@ -1,4 +1,4 @@ -package dpopoauth2 +package dpop import ( "context" diff --git a/libbeat/common/dpopoauth2/jwk.go b/x-pack/filebeat/input/internal/dpop/jwk.go similarity index 99% rename from libbeat/common/dpopoauth2/jwk.go rename to x-pack/filebeat/input/internal/dpop/jwk.go index f7e9eb1fb4ba..b45cf344a89d 100644 --- a/libbeat/common/dpopoauth2/jwk.go +++ b/x-pack/filebeat/input/internal/dpop/jwk.go @@ -1,4 +1,4 @@ -package dpopoauth2 +package dpop import ( "crypto/ecdsa" diff --git a/libbeat/common/dpopoauth2/proof.go b/x-pack/filebeat/input/internal/dpop/proof.go similarity index 99% rename from libbeat/common/dpopoauth2/proof.go rename to x-pack/filebeat/input/internal/dpop/proof.go index 840726dc8320..a14490ff7b63 100644 --- a/libbeat/common/dpopoauth2/proof.go +++ b/x-pack/filebeat/input/internal/dpop/proof.go @@ -1,4 +1,4 @@ -package dpopoauth2 +package dpop import ( "context" diff --git a/libbeat/common/dpopoauth2/proof_test.go b/x-pack/filebeat/input/internal/dpop/proof_test.go similarity index 99% rename from libbeat/common/dpopoauth2/proof_test.go rename to x-pack/filebeat/input/internal/dpop/proof_test.go index df27ac7bc6ff..71b5c8baf31c 100644 --- a/libbeat/common/dpopoauth2/proof_test.go +++ b/x-pack/filebeat/input/internal/dpop/proof_test.go @@ -1,4 +1,4 @@ -package dpopoauth2 +package dpop import ( "context" diff --git a/libbeat/common/dpopoauth2/signature.go b/x-pack/filebeat/input/internal/dpop/signature.go similarity index 98% rename from libbeat/common/dpopoauth2/signature.go rename to x-pack/filebeat/input/internal/dpop/signature.go index d924c9ea14e6..2b74f62c47af 100644 --- a/libbeat/common/dpopoauth2/signature.go +++ b/x-pack/filebeat/input/internal/dpop/signature.go @@ -1,4 +1,4 @@ -package dpopoauth2 +package dpop import ( "crypto" diff --git a/libbeat/common/dpopoauth2/token_transport.go b/x-pack/filebeat/input/internal/dpop/token_transport.go similarity index 98% rename from libbeat/common/dpopoauth2/token_transport.go rename to x-pack/filebeat/input/internal/dpop/token_transport.go index a92fafb85520..8c1f56eefb9a 100644 --- a/libbeat/common/dpopoauth2/token_transport.go +++ b/x-pack/filebeat/input/internal/dpop/token_transport.go @@ -1,4 +1,4 @@ -package dpopoauth2 +package dpop import ( "errors" From 4223c09654e7e00284b0fd5889b4a5da8bd26403 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Oct 2025 04:44:38 +0000 Subject: [PATCH 3/4] Refactor: Improve DPoP client and JWK generation logic Co-authored-by: robert.kortschak --- x-pack/filebeat/input/internal/dpop/client.go | 16 ++--- x-pack/filebeat/input/internal/dpop/jwk.go | 14 +++- x-pack/filebeat/input/internal/dpop/proof.go | 33 ++++++---- .../filebeat/input/internal/dpop/signature.go | 64 +++++++++++-------- .../input/internal/dpop/token_transport.go | 2 + 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/x-pack/filebeat/input/internal/dpop/client.go b/x-pack/filebeat/input/internal/dpop/client.go index a31c4ca0aad9..083e817f46bf 100644 --- a/x-pack/filebeat/input/internal/dpop/client.go +++ b/x-pack/filebeat/input/internal/dpop/client.go @@ -1,15 +1,15 @@ package dpop import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "errors" - "net/http" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "errors" + "net/http" - "golang.org/x/oauth2" + "golang.org/x/oauth2" ) // NewTokenClient builds an *http.Client to be used by oauth2.Config or clientcredentials.Config diff --git a/x-pack/filebeat/input/internal/dpop/jwk.go b/x-pack/filebeat/input/internal/dpop/jwk.go index b45cf344a89d..aa1a809cf1a2 100644 --- a/x-pack/filebeat/input/internal/dpop/jwk.go +++ b/x-pack/filebeat/input/internal/dpop/jwk.go @@ -9,14 +9,16 @@ import ( "math/big" ) -// jwkPublic represents a minimal JSON Web Key (public part) used in DPoP headers. -// We only include the required members per RFCs for thumbprint stability. -// Use map[string]interface{} when attaching to JOSE header. +// 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" ) @@ -35,6 +37,8 @@ func buildJWKAndAlg(privateKey interface{}) (map[string]interface{}, keyAlgorith } } +// 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") @@ -58,6 +62,8 @@ func ecPublicJWK(pub *ecdsa.PublicKey) (map[string]interface{}, keyAlgorithm, er 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") @@ -72,6 +78,8 @@ func rsaPublicJWK(pub *rsa.PublicKey) (map[string]interface{}, keyAlgorithm, err 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 diff --git a/x-pack/filebeat/input/internal/dpop/proof.go b/x-pack/filebeat/input/internal/dpop/proof.go index a14490ff7b63..aede3fc48003 100644 --- a/x-pack/filebeat/input/internal/dpop/proof.go +++ b/x-pack/filebeat/input/internal/dpop/proof.go @@ -1,18 +1,18 @@ package dpop import ( - "context" - "crypto/ecdsa" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "strings" - "time" + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "time" - "golang.org/x/oauth2" + "golang.org/x/oauth2" ) // ProofOptions holds optional values like nonce and access token hash (ath). @@ -44,6 +44,9 @@ func NewProofGenerator(privateKey interface{}) (*ProofGenerator, error) { } // BuildProof produces a compact JWS string containing the DPoP proof for the given HTTP method and URL. +// 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") @@ -77,12 +80,15 @@ func (g *ProofGenerator) BuildProof(ctx context.Context, method, url string, opt return signJWS(g.privateKey, header, claims) } +// 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) } +// signJWS produces a compact JWS string by signing the given header and claims +// with the provided private key, using the algorithm implied by the key type. func signJWS(privateKey interface{}, header, claims map[string]interface{}) (string, error) { hb, err := json.Marshal(header) if err != nil { @@ -103,6 +109,8 @@ func signJWS(privateKey interface{}, header, claims map[string]interface{}) (str return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil } +// signDetached signs the given data using the appropriate algorithm for +// the provided key and returns the raw signature bytes. func signDetached(privateKey interface{}, data string) ([]byte, error) { switch k := privateKey.(type) { case *ecdsa.PrivateKey: @@ -117,6 +125,9 @@ func signDetached(privateKey interface{}, data string) ([]byte, error) { // Transport decorates an underlying RoundTripper to add DPoP proofs and bearer auth. // It uses the provided TokenSource for access tokens and adds both Authorization and DPoP headers. +// 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 diff --git a/x-pack/filebeat/input/internal/dpop/signature.go b/x-pack/filebeat/input/internal/dpop/signature.go index 2b74f62c47af..ec2c70762c1d 100644 --- a/x-pack/filebeat/input/internal/dpop/signature.go +++ b/x-pack/filebeat/input/internal/dpop/signature.go @@ -1,44 +1,52 @@ package dpop import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "encoding/base64" - "errors" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "errors" ) +// 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 + 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) + 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. func signECDSA(priv *ecdsa.PrivateKey, data []byte) ([]byte, error) { - h := sha256.Sum256(data) - r, s, err := ecdsa.Sign(rand.Reader, priv, h[:]) - if err != nil { - return nil, err - } - // DPoP ES256 requires the raw concatenation of r and s (not DER) - curveBits := priv.Curve.Params().BitSize - if priv.Curve != elliptic.P256() { - return nil, errors.New("unsupported ECDSA curve for ES256") - } - octLen := (curveBits + 7) / 8 - rb := leftPadToSize(r.Bytes(), octLen) - sb := leftPadToSize(s.Bytes(), octLen) - return append(rb, sb...), nil + h := sha256.Sum256(data) + r, s, err := ecdsa.Sign(rand.Reader, priv, h[:]) + if err != nil { + return nil, err + } + // DPoP ES256 requires the raw concatenation of r and s (not DER) + curveBits := priv.Curve.Params().BitSize + if priv.Curve != elliptic.P256() { + return nil, errors.New("unsupported ECDSA curve for ES256") + } + octLen := (curveBits + 7) / 8 + rb := leftPadToSize(r.Bytes(), octLen) + sb := leftPadToSize(s.Bytes(), octLen) + return append(rb, sb...), nil } +// signRSA creates an RS256 signature over data (with SHA-256) per PKCS#1 v1.5. func signRSA(priv *rsa.PrivateKey, data []byte) ([]byte, error) { - h := sha256.Sum256(data) - return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) + h := sha256.Sum256(data) + return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) } diff --git a/x-pack/filebeat/input/internal/dpop/token_transport.go b/x-pack/filebeat/input/internal/dpop/token_transport.go index 8c1f56eefb9a..14be48405252 100644 --- a/x-pack/filebeat/input/internal/dpop/token_transport.go +++ b/x-pack/filebeat/input/internal/dpop/token_transport.go @@ -14,6 +14,8 @@ type TokenTransport struct { 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 { From a41879b236e2a4e9b928a453e5c6c289efba9b6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Oct 2025 02:38:21 +0000 Subject: [PATCH 4/4] Refactor: Use golang-jwt/jwt/v5 for DPoP signing This commit updates the DPoP implementation to use the `golang-jwt/jwt/v5` library for signing JWTs. This simplifies the code by delegating the signing logic to the library, removing the need for custom signing functions. The `go.mod` and `go.sum` files have been updated to reflect the new dependency. Co-authored-by: robert.kortschak --- go.mod | 5 +- go.sum | 2 + x-pack/filebeat/input/internal/dpop/proof.go | 69 ++++++------------- .../filebeat/input/internal/dpop/signature.go | 33 +-------- 4 files changed, 30 insertions(+), 79 deletions(-) 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/proof.go b/x-pack/filebeat/input/internal/dpop/proof.go index aede3fc48003..7776facd14fa 100644 --- a/x-pack/filebeat/input/internal/dpop/proof.go +++ b/x-pack/filebeat/input/internal/dpop/proof.go @@ -2,16 +2,14 @@ package dpop import ( "context" - "crypto/ecdsa" "crypto/rand" - "crypto/rsa" "encoding/base64" - "encoding/json" "errors" "net/http" "strings" "time" + jwt "github.com/golang-jwt/jwt/v5" "golang.org/x/oauth2" ) @@ -43,7 +41,6 @@ func NewProofGenerator(privateKey interface{}) (*ProofGenerator, error) { return &ProofGenerator{privateKey: privateKey, jwk: jwk, alg: alg}, nil } -// BuildProof produces a compact JWS string containing the DPoP proof for the given HTTP method and URL. // 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. @@ -55,13 +52,8 @@ func (g *ProofGenerator) BuildProof(ctx context.Context, method, url string, opt if i := strings.Index(htu, "#"); i >= 0 { // strip fragment htu = htu[:i] } - header := map[string]interface{}{ - "typ": "dpop+jwt", - "alg": string(g.alg), - "jwk": g.jwk, - } now := time.Now().Unix() - claims := map[string]interface{}{ + claims := jwt.MapClaims{ "htu": htu, "htm": strings.ToUpper(method), "iat": now, @@ -77,57 +69,38 @@ func (g *ProofGenerator) BuildProof(ctx context.Context, method, url string, opt } claims["ath"] = h } - return signJWS(g.privateKey, header, claims) -} - -// 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) -} -// signJWS produces a compact JWS string by signing the given header and claims -// with the provided private key, using the algorithm implied by the key type. -func signJWS(privateKey interface{}, header, claims map[string]interface{}) (string, error) { - hb, err := json.Marshal(header) - if err != nil { - return "", err - } - cb, err := json.Marshal(claims) - if err != nil { - return "", err + 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") } - encHeader := base64.RawURLEncoding.EncodeToString(hb) - encPayload := base64.RawURLEncoding.EncodeToString(cb) - unsigned := encHeader + "." + encPayload + token := jwt.NewWithClaims(methodSig, claims) + token.Header["typ"] = "dpop+jwt" + token.Header["jwk"] = g.jwk - sig, err := signDetached(privateKey, unsigned) + signed, err := token.SignedString(g.privateKey) if err != nil { return "", err } - return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil + return signed, nil } -// signDetached signs the given data using the appropriate algorithm for -// the provided key and returns the raw signature bytes. -func signDetached(privateKey interface{}, data string) ([]byte, error) { - switch k := privateKey.(type) { - case *ecdsa.PrivateKey: - return signECDSA(k, []byte(data)) - case *rsa.PrivateKey: - return signRSA(k, []byte(data)) - default: - return nil, errors.New("unsupported private key type for signing") - } +// 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 decorates an underlying RoundTripper to add DPoP proofs and bearer auth. -// It uses the provided TokenSource for access tokens and adds both Authorization and DPoP headers. - // 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 diff --git a/x-pack/filebeat/input/internal/dpop/signature.go b/x-pack/filebeat/input/internal/dpop/signature.go index ec2c70762c1d..5b96f2cf0405 100644 --- a/x-pack/filebeat/input/internal/dpop/signature.go +++ b/x-pack/filebeat/input/internal/dpop/signature.go @@ -1,14 +1,8 @@ package dpop import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "encoding/base64" - "errors" + "crypto/sha256" + "encoding/base64" ) // Helpers to hash and sign DPoP proof inputs. @@ -28,25 +22,4 @@ func base64RawURLEncode(b []byte) string { // signECDSA creates an ES256 signature over data (with SHA-256) and returns // the raw (r || s) concatenation, as required by JOSE for ES256. -func signECDSA(priv *ecdsa.PrivateKey, data []byte) ([]byte, error) { - h := sha256.Sum256(data) - r, s, err := ecdsa.Sign(rand.Reader, priv, h[:]) - if err != nil { - return nil, err - } - // DPoP ES256 requires the raw concatenation of r and s (not DER) - curveBits := priv.Curve.Params().BitSize - if priv.Curve != elliptic.P256() { - return nil, errors.New("unsupported ECDSA curve for ES256") - } - octLen := (curveBits + 7) / 8 - rb := leftPadToSize(r.Bytes(), octLen) - sb := leftPadToSize(s.Bytes(), octLen) - return append(rb, sb...), nil -} - -// signRSA creates an RS256 signature over data (with SHA-256) per PKCS#1 v1.5. -func signRSA(priv *rsa.PrivateKey, data []byte) ([]byte, error) { - h := sha256.Sum256(data) - return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) -} +// Signing is delegated to the jwt/v5 library in proof.go