Skip to content
Closed
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
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
60 changes: 60 additions & 0 deletions x-pack/filebeat/input/internal/dpop/client.go
Original file line number Diff line number Diff line change
@@ -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 <access_token> 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)
}
90 changes: 90 additions & 0 deletions x-pack/filebeat/input/internal/dpop/jwk.go
Original file line number Diff line number Diff line change
@@ -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
}
153 changes: 153 additions & 0 deletions x-pack/filebeat/input/internal/dpop/proof.go
Original file line number Diff line number Diff line change
@@ -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 <access_token>) 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
}
Loading