Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

node,rpc: JWT auth dialing + auth endpoints getters + auth testing #24911

Merged
merged 11 commits into from
Sep 2, 2022
2 changes: 1 addition & 1 deletion node/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ type Config struct {
// AllowUnprotectedTxs allows non EIP-155 protected transactions to be send over RPC.
AllowUnprotectedTxs bool `toml:",omitempty"`

// JWTSecret is the hex-encoded jwt secret.
// JWTSecret is the path to the hex-encoded jwt secret.
JWTSecret string `toml:",omitempty"`
}

Expand Down
45 changes: 45 additions & 0 deletions node/jwt_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2022 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.

package node

import (
"fmt"
"net/http"
"time"

"github.com/ethereum/go-ethereum/rpc"
"github.com/golang-jwt/jwt/v4"
)

// NewJWTAuth creates an rpc client authentication provider that uses JWT. The
// secret MUST be 32 bytes (256 bits) as defined by the Engine-API authentication spec.
//
// See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md
// for more details about this authentication scheme.
func NewJWTAuth(jwtsecret [32]byte) rpc.HTTPAuth {
return func(h http.Header) error {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iat": &jwt.NumericDate{Time: time.Now()},
})
s, err := token.SignedString(jwtsecret[:])
if err != nil {
return fmt.Errorf("failed to create JWT token: %w", err)
}
h.Set("Authorization", "Bearer "+s)
return nil
}
}
13 changes: 13 additions & 0 deletions node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,19 @@ func (n *Node) WSEndpoint() string {
return "ws://" + n.ws.listenAddr() + n.ws.wsConfig.prefix
}

// HTTPAuthEndpoint returns the URL of the authenticated HTTP server.
func (n *Node) HTTPAuthEndpoint() string {
return "http://" + n.httpAuth.listenAddr()
}

// WSAuthEndpoint returns the current authenticated JSON-RPC over WebSocket endpoint.
func (n *Node) WSAuthEndpoint() string {
if n.httpAuth.wsAllowed() {
return "ws://" + n.httpAuth.listenAddr() + n.httpAuth.wsConfig.prefix
}
return "ws://" + n.wsAuth.listenAddr() + n.wsAuth.wsConfig.prefix
}

// EventMux retrieves the event multiplexer used by all the network services in
// the current protocol stack.
func (n *Node) EventMux() *event.TypeMux {
Expand Down
237 changes: 237 additions & 0 deletions node/node_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// Copyright 2022 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.

package node
protolambda marked this conversation as resolved.
Show resolved Hide resolved

import (
"context"
crand "crypto/rand"
"fmt"
"net/http"
"os"
"path"
"testing"
"time"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/rpc"
"github.com/golang-jwt/jwt/v4"
)

type helloRPC string

func (ta helloRPC) HelloWorld() (string, error) {
return string(ta), nil
}

type authTest struct {
name string
endpoint string
prov rpc.HTTPAuth
expectDialFail bool
expectCall1Fail bool
expectCall2Fail bool
}

func (at *authTest) Run(t *testing.T) {
ctx := context.Background()
cl, err := rpc.DialOptions(ctx, at.endpoint, rpc.WithHTTPAuth(at.prov))
if at.expectDialFail {
if err == nil {
t.Fatal("expected initial dial to fail")
} else {
return
}
}
if err != nil {
t.Fatalf("failed to dial rpc endpoint: %v", err)
}

var x string
err = cl.CallContext(ctx, &x, "engine_helloWorld")
if at.expectCall1Fail {
if err == nil {
t.Fatal("expected call 1 to fail")
} else {
return
}
}
if err != nil {
t.Fatalf("failed to call rpc endpoint: %v", err)
}
if x != "hello engine" {
t.Fatalf("method was silent but did not return expected value: %q", x)
}

err = cl.CallContext(ctx, &x, "eth_helloWorld")
if at.expectCall2Fail {
if err == nil {
t.Fatal("expected call 2 to fail")
} else {
return
}
}
if err != nil {
t.Fatalf("failed to call rpc endpoint: %v", err)
}
if x != "hello eth" {
t.Fatalf("method was silent but did not return expected value: %q", x)
}
}

func TestAuthEndpoints(t *testing.T) {
var secret [32]byte
if _, err := crand.Read(secret[:]); err != nil {
t.Fatalf("failed to create jwt secret: %v", err)
}
// Geth must read it from a file, and does not support in-memory JWT secrets, so we create a temporary file.
jwtPath := path.Join(t.TempDir(), "jwt_secret")
if err := os.WriteFile(jwtPath, []byte(hexutil.Encode(secret[:])), 0600); err != nil {
t.Fatalf("failed to prepare jwt secret file: %v", err)
}
// We get ports assigned by the node automatically
conf := &Config{
HTTPHost: "127.0.0.1",
HTTPPort: 0,
WSHost: "127.0.0.1",
WSPort: 0,
AuthAddr: "127.0.0.1",
AuthPort: 0,
JWTSecret: jwtPath,

WSModules: []string{"eth", "engine"},
HTTPModules: []string{"eth", "engine"},
Comment on lines +115 to +116
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe these are actually ignored by the Node.startRPC.initAuth func. Instead it uses DefaultAuthModules, and doesn't make them configurable yet. Should we make the exposed auth modules configurable?

}
node, err := New(conf)
if err != nil {
t.Fatalf("could not create a new node: %v", err)
}
// register dummy apis so we can test the modules are available and reachable with authentication
node.RegisterAPIs([]rpc.API{
{
Namespace: "engine",
Version: "1.0",
Service: helloRPC("hello engine"),
Public: true,
Authenticated: true,
},
{
Namespace: "eth",
Version: "1.0",
Service: helloRPC("hello eth"),
Public: true,
Authenticated: true,
},
})
if err := node.Start(); err != nil {
t.Fatalf("failed to start test node: %v", err)
}
defer node.Close()

// sanity check we are running different endpoints
if a, b := node.WSEndpoint(), node.WSAuthEndpoint(); a == b {
t.Fatalf("expected ws and auth-ws endpoints to be different, got: %q and %q", a, b)
}
if a, b := node.HTTPEndpoint(), node.HTTPAuthEndpoint(); a == b {
t.Fatalf("expected http and auth-http endpoints to be different, got: %q and %q", a, b)
}

goodAuth := NewJWTAuth(secret)
var otherSecret [32]byte
if _, err := crand.Read(otherSecret[:]); err != nil {
t.Fatalf("failed to create jwt secret: %v", err)
}
badAuth := NewJWTAuth(otherSecret)

notTooLong := time.Second * 57
tooLong := time.Second * 60
requestDelay := time.Second

testCases := []authTest{
// Auth works
{name: "ws good", endpoint: node.WSAuthEndpoint(), prov: goodAuth, expectCall1Fail: false},
{name: "http good", endpoint: node.HTTPAuthEndpoint(), prov: goodAuth, expectCall1Fail: false},

// Try a bad auth
{name: "ws bad", endpoint: node.WSAuthEndpoint(), prov: badAuth, expectDialFail: true}, // ws auth is immediate
{name: "http bad", endpoint: node.HTTPAuthEndpoint(), prov: badAuth, expectCall1Fail: true}, // http auth is on first call

// A common mistake with JWT is to allow the "none" algorithm, which is a valid JWT but not secure.
{name: "ws none", endpoint: node.WSAuthEndpoint(), prov: noneAuth(secret), expectDialFail: true},
{name: "http none", endpoint: node.HTTPAuthEndpoint(), prov: noneAuth(secret), expectCall1Fail: true},

// claims of 5 seconds or more, older or newer, are not allowed
{name: "ws too old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, -tooLong), expectDialFail: true},
{name: "http too old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, -tooLong), expectCall1Fail: true},
// note: for it to be too long we need to add a delay, so that once we receive the request, the difference has not dipped below the "tooLong"
{name: "ws too new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, tooLong+requestDelay), expectDialFail: true},
{name: "http too new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, tooLong+requestDelay), expectCall1Fail: true},

// Try offset the time, but stay just within bounds
{name: "ws old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, -notTooLong)},
{name: "http old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, -notTooLong)},
{name: "ws new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, notTooLong)},
{name: "http new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, notTooLong)},

// ws only authenticates on initial dial, then continues communication
{name: "ws single auth", endpoint: node.WSAuthEndpoint(), prov: changingAuth(goodAuth, badAuth)},
{name: "http call fail auth", endpoint: node.HTTPAuthEndpoint(), prov: changingAuth(goodAuth, badAuth), expectCall2Fail: true},
{name: "http call fail time", endpoint: node.HTTPAuthEndpoint(), prov: changingAuth(goodAuth, offsetTimeAuth(secret, tooLong+requestDelay)), expectCall2Fail: true},
}

for _, testCase := range testCases {
t.Run(testCase.name, testCase.Run)
}
}

func noneAuth(secret [32]byte) rpc.HTTPAuth {
return func(header http.Header) error {
token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{
"iat": &jwt.NumericDate{Time: time.Now()},
})
s, err := token.SignedString(secret[:])
if err != nil {
return fmt.Errorf("failed to create JWT token: %w", err)
}
header.Set("Authorization", "Bearer "+s)
return nil
}
}

func changingAuth(provs ...rpc.HTTPAuth) rpc.HTTPAuth {
i := 0
return func(header http.Header) error {
i += 1
if i > len(provs) {
i = len(provs)
}
return provs[i-1](header)
}
}

func offsetTimeAuth(secret [32]byte, offset time.Duration) rpc.HTTPAuth {
return func(header http.Header) error {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iat": &jwt.NumericDate{Time: time.Now().Add(offset)},
})
s, err := token.SignedString(secret[:])
if err != nil {
return fmt.Errorf("failed to create JWT token: %w", err)
}
header.Set("Authorization", "Bearer "+s)
return nil
}
}
Loading