diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..402c3f0 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +name: Unit tests + +on: + pull_request: + types: [opened, ready_for_review] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install just + uses: taiki-e/install-action@just + + - name: Run tests + run: just test diff --git a/go.mod b/go.mod index f0a145e..44e65be 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.24.3 require ( github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/lestrrat-go/jwx v1.2.31 + github.com/stretchr/testify v1.11.1 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 @@ -12,18 +14,25 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect @@ -34,6 +43,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect diff --git a/go.sum b/go.sum index b535943..6e68e45 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -18,6 +20,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -44,6 +48,19 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.31 h1:/OM9oNl/fzyldpv5HKZ9m7bTywa7COUfg8gujd9nJ54= +github.com/lestrrat-go/jwx v1.2.31/go.mod h1:eQJKoRwWcLg4PfD5CFA5gIZGxhPgoPYq9pZISdxLf0c= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -72,11 +89,12 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -90,6 +108,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= diff --git a/k8s.go b/k8s.go index f9bb467..af49b56 100644 --- a/k8s.go +++ b/k8s.go @@ -5,13 +5,10 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" - "encoding/base64" - "encoding/binary" "encoding/json" "errors" "fmt" "log/slog" - "math/big" "net/http" "os" "strings" @@ -19,6 +16,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/jsnctl/kubespiffe/pkg/generated/clientset/versioned" + "github.com/lestrrat-go/jwx/jwk" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -103,12 +101,12 @@ func loadCertPool(path string) (*x509.CertPool, error) { return pool, nil } -func extractBearer(header string) string { - prefix := "Bearer " - if len(header) > len(prefix) && header[:len(prefix)] == prefix { - return header[len(prefix):] +func extractBearerToken(header string) string { + hasToken := strings.HasPrefix(header, "Bearer ") + if !hasToken { + return "" } - return "" + return strings.TrimPrefix(header, "Bearer ") } func verifyPSAT(psat string, jwks *JWKS) (map[string]any, error) { @@ -182,38 +180,23 @@ func findKeyByKID(jwks *JWKS, kid string) (map[string]interface{}, error) { return nil, fmt.Errorf("no key found for kid: %s", kid) } -func jwkToPublicKey(jwk map[string]interface{}) (*rsa.PublicKey, error) { - // TODO: This can definitely be simplified with the Go JWT - // library API, this fn was original provided by Gemini and is likely - // to be over-engineered and potentially buggy - nStr, okN := jwk["n"].(string) - eStr, okE := jwk["e"].(string) - if !okN || !okE { - return nil, errors.New("missing n or e in jwk") - } - - nBytes, err := base64.RawURLEncoding.DecodeString(nStr) +func jwkToPublicKey(keyMap map[string]interface{}) (*rsa.PublicKey, error) { + keyData, err := json.Marshal(keyMap) if err != nil { - return nil, fmt.Errorf("decode n: %w", err) + return nil, fmt.Errorf("problem marshaling JWK: %w", err) } - eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + + key, err := jwk.ParseKey(keyData) if err != nil { - return nil, fmt.Errorf("decode e: %w", err) + return nil, fmt.Errorf("problem with parsing JWK: %w", err) } - var e int - switch len(eBytes) { - case 3: - e = int(binary.BigEndian.Uint32(append([]byte{0}, eBytes...))) - default: - e = int(binary.BigEndian.Uint16(eBytes)) + var publicKey rsa.PublicKey + if err := key.Raw(&publicKey); err != nil { + return nil, fmt.Errorf("problem extracting key: %w", err) } - pub := &rsa.PublicKey{ - N: new(big.Int).SetBytes(nBytes), - E: e, - } - return pub, nil + return &publicKey, nil } type KubernetesWorkloadClaims struct { diff --git a/k8s_test.go b/k8s_test.go new file mode 100644 index 0000000..caff830 --- /dev/null +++ b/k8s_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_extractBearer(t *testing.T) { + tests := []struct { + name string + header string + want string + }{ + { + name: "valid bearer token", + header: "Bearer i-am-a-bearer-token", + want: "i-am-a-bearer-token", + }, + { + name: "missing prefix", + header: "i-might-be-a-bearer-token-but-i-have-no-Bearer-before-me", + want: "", + }, + { + name: "empty header", + header: "", + want: "", + }, + { + name: "prefix only", + header: "Bearer ", + want: "", + }, + { + name: "case sensitive prefix", + header: "bearer i-could-be-a-bearer-token-but-the-guy-before-me-ruined-it", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractBearerToken(tt.header) + assert.Equal(t, got, tt.want) + }) + } +} + +func mockRSAJWK() map[string]any { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil + } + + nStr := base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.N.Bytes()) + + eBytes := big.NewInt(int64(privateKey.PublicKey.E)).Bytes() + eStr := base64.RawURLEncoding.EncodeToString(eBytes) + + return map[string]any{ + "kty": "RSA", + "n": nStr, + "e": eStr, + } +} + +func Test_jwkToPublicKey(t *testing.T) { + validJWK := mockRSAJWK() + + tests := []struct { + name string + jwk map[string]any + wantErr bool + }{ + { + name: "valid JWK", + jwk: validJWK, + wantErr: false, + }, + { + name: "missing n", + jwk: map[string]any{ + "e": validJWK["e"], + }, + wantErr: true, + }, + { + name: "missing e", + jwk: map[string]any{ + "n": validJWK["n"], + }, + wantErr: true, + }, + { + name: "invalid base64", + jwk: map[string]any{ + "n": "inv@lid", + "e": validJWK["e"], + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := jwkToPublicKey(tt.jwk) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.IsType(t, &rsa.PublicKey{}, got) + }) + } +} diff --git a/main.go b/main.go index eabbadf..8c72f0d 100644 --- a/main.go +++ b/main.go @@ -23,7 +23,7 @@ func main() { log.Fatalf("problem with kubespiffe clientset: %v", err) } http.HandleFunc("/v1/svid", func(w http.ResponseWriter, r *http.Request) { - token := extractBearer(r.Header.Get("Authorization")) + token := extractBearerToken(r.Header.Get("Authorization")) if token == "" { http.Error(w, "missing bearer token", http.StatusUnauthorized) return