Skip to content

Commit c384f82

Browse files
protolambdatrianglespherefjl
authored andcommitted
node, rpc: add JWT auth support in client (ethereum#24911)
This adds a generic mechanism for 'dial options' in the RPC client, and also implements a specific dial option for the JWT authentication mechanism used by the engine API. Some real tests for the server-side authentication handling are also added. Co-authored-by: Joshua Gutow <[email protected]> Co-authored-by: Felix Lange <[email protected]>
1 parent cf9d288 commit c384f82

File tree

11 files changed

+573
-48
lines changed

11 files changed

+573
-48
lines changed

node/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ type Config struct {
201201
// AllowUnprotectedTxs allows non EIP-155 protected transactions to be send over RPC.
202202
AllowUnprotectedTxs bool `toml:",omitempty"`
203203

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

node/jwt_auth.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2022 The go-ethereum Authors
2+
// This file is part of the go-ethereum library.
3+
//
4+
// The go-ethereum library is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// The go-ethereum library is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package node
18+
19+
import (
20+
"fmt"
21+
"net/http"
22+
"time"
23+
24+
"github.com/ethereum/go-ethereum/rpc"
25+
"github.com/golang-jwt/jwt/v4"
26+
)
27+
28+
// NewJWTAuth creates an rpc client authentication provider that uses JWT. The
29+
// secret MUST be 32 bytes (256 bits) as defined by the Engine-API authentication spec.
30+
//
31+
// See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md
32+
// for more details about this authentication scheme.
33+
func NewJWTAuth(jwtsecret [32]byte) rpc.HTTPAuth {
34+
return func(h http.Header) error {
35+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
36+
"iat": &jwt.NumericDate{Time: time.Now()},
37+
})
38+
s, err := token.SignedString(jwtsecret[:])
39+
if err != nil {
40+
return fmt.Errorf("failed to create JWT token: %w", err)
41+
}
42+
h.Set("Authorization", "Bearer "+s)
43+
return nil
44+
}
45+
}

node/node.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,19 @@ func (n *Node) WSEndpoint() string {
668668
return "ws://" + n.ws.listenAddr() + n.ws.wsConfig.prefix
669669
}
670670

671+
// HTTPAuthEndpoint returns the URL of the authenticated HTTP server.
672+
func (n *Node) HTTPAuthEndpoint() string {
673+
return "http://" + n.httpAuth.listenAddr()
674+
}
675+
676+
// WSAuthEndpoint returns the current authenticated JSON-RPC over WebSocket endpoint.
677+
func (n *Node) WSAuthEndpoint() string {
678+
if n.httpAuth.wsAllowed() {
679+
return "ws://" + n.httpAuth.listenAddr() + n.httpAuth.wsConfig.prefix
680+
}
681+
return "ws://" + n.wsAuth.listenAddr() + n.wsAuth.wsConfig.prefix
682+
}
683+
671684
// EventMux retrieves the event multiplexer used by all the network services in
672685
// the current protocol stack.
673686
func (n *Node) EventMux() *event.TypeMux {

node/node_auth_test.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Copyright 2022 The go-ethereum Authors
2+
// This file is part of the go-ethereum library.
3+
//
4+
// The go-ethereum library is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// The go-ethereum library is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package node
18+
19+
import (
20+
"context"
21+
crand "crypto/rand"
22+
"fmt"
23+
"net/http"
24+
"os"
25+
"path"
26+
"testing"
27+
"time"
28+
29+
"github.com/ethereum/go-ethereum/common/hexutil"
30+
"github.com/ethereum/go-ethereum/rpc"
31+
"github.com/golang-jwt/jwt/v4"
32+
)
33+
34+
type helloRPC string
35+
36+
func (ta helloRPC) HelloWorld() (string, error) {
37+
return string(ta), nil
38+
}
39+
40+
type authTest struct {
41+
name string
42+
endpoint string
43+
prov rpc.HTTPAuth
44+
expectDialFail bool
45+
expectCall1Fail bool
46+
expectCall2Fail bool
47+
}
48+
49+
func (at *authTest) Run(t *testing.T) {
50+
ctx := context.Background()
51+
cl, err := rpc.DialOptions(ctx, at.endpoint, rpc.WithHTTPAuth(at.prov))
52+
if at.expectDialFail {
53+
if err == nil {
54+
t.Fatal("expected initial dial to fail")
55+
} else {
56+
return
57+
}
58+
}
59+
if err != nil {
60+
t.Fatalf("failed to dial rpc endpoint: %v", err)
61+
}
62+
63+
var x string
64+
err = cl.CallContext(ctx, &x, "engine_helloWorld")
65+
if at.expectCall1Fail {
66+
if err == nil {
67+
t.Fatal("expected call 1 to fail")
68+
} else {
69+
return
70+
}
71+
}
72+
if err != nil {
73+
t.Fatalf("failed to call rpc endpoint: %v", err)
74+
}
75+
if x != "hello engine" {
76+
t.Fatalf("method was silent but did not return expected value: %q", x)
77+
}
78+
79+
err = cl.CallContext(ctx, &x, "eth_helloWorld")
80+
if at.expectCall2Fail {
81+
if err == nil {
82+
t.Fatal("expected call 2 to fail")
83+
} else {
84+
return
85+
}
86+
}
87+
if err != nil {
88+
t.Fatalf("failed to call rpc endpoint: %v", err)
89+
}
90+
if x != "hello eth" {
91+
t.Fatalf("method was silent but did not return expected value: %q", x)
92+
}
93+
}
94+
95+
func TestAuthEndpoints(t *testing.T) {
96+
var secret [32]byte
97+
if _, err := crand.Read(secret[:]); err != nil {
98+
t.Fatalf("failed to create jwt secret: %v", err)
99+
}
100+
// Geth must read it from a file, and does not support in-memory JWT secrets, so we create a temporary file.
101+
jwtPath := path.Join(t.TempDir(), "jwt_secret")
102+
if err := os.WriteFile(jwtPath, []byte(hexutil.Encode(secret[:])), 0600); err != nil {
103+
t.Fatalf("failed to prepare jwt secret file: %v", err)
104+
}
105+
// We get ports assigned by the node automatically
106+
conf := &Config{
107+
HTTPHost: "127.0.0.1",
108+
HTTPPort: 0,
109+
WSHost: "127.0.0.1",
110+
WSPort: 0,
111+
AuthAddr: "127.0.0.1",
112+
AuthPort: 0,
113+
JWTSecret: jwtPath,
114+
115+
WSModules: []string{"eth", "engine"},
116+
HTTPModules: []string{"eth", "engine"},
117+
}
118+
node, err := New(conf)
119+
if err != nil {
120+
t.Fatalf("could not create a new node: %v", err)
121+
}
122+
// register dummy apis so we can test the modules are available and reachable with authentication
123+
node.RegisterAPIs([]rpc.API{
124+
{
125+
Namespace: "engine",
126+
Version: "1.0",
127+
Service: helloRPC("hello engine"),
128+
Public: true,
129+
Authenticated: true,
130+
},
131+
{
132+
Namespace: "eth",
133+
Version: "1.0",
134+
Service: helloRPC("hello eth"),
135+
Public: true,
136+
Authenticated: true,
137+
},
138+
})
139+
if err := node.Start(); err != nil {
140+
t.Fatalf("failed to start test node: %v", err)
141+
}
142+
defer node.Close()
143+
144+
// sanity check we are running different endpoints
145+
if a, b := node.WSEndpoint(), node.WSAuthEndpoint(); a == b {
146+
t.Fatalf("expected ws and auth-ws endpoints to be different, got: %q and %q", a, b)
147+
}
148+
if a, b := node.HTTPEndpoint(), node.HTTPAuthEndpoint(); a == b {
149+
t.Fatalf("expected http and auth-http endpoints to be different, got: %q and %q", a, b)
150+
}
151+
152+
goodAuth := NewJWTAuth(secret)
153+
var otherSecret [32]byte
154+
if _, err := crand.Read(otherSecret[:]); err != nil {
155+
t.Fatalf("failed to create jwt secret: %v", err)
156+
}
157+
badAuth := NewJWTAuth(otherSecret)
158+
159+
notTooLong := time.Second * 57
160+
tooLong := time.Second * 60
161+
requestDelay := time.Second
162+
163+
testCases := []authTest{
164+
// Auth works
165+
{name: "ws good", endpoint: node.WSAuthEndpoint(), prov: goodAuth, expectCall1Fail: false},
166+
{name: "http good", endpoint: node.HTTPAuthEndpoint(), prov: goodAuth, expectCall1Fail: false},
167+
168+
// Try a bad auth
169+
{name: "ws bad", endpoint: node.WSAuthEndpoint(), prov: badAuth, expectDialFail: true}, // ws auth is immediate
170+
{name: "http bad", endpoint: node.HTTPAuthEndpoint(), prov: badAuth, expectCall1Fail: true}, // http auth is on first call
171+
172+
// A common mistake with JWT is to allow the "none" algorithm, which is a valid JWT but not secure.
173+
{name: "ws none", endpoint: node.WSAuthEndpoint(), prov: noneAuth(secret), expectDialFail: true},
174+
{name: "http none", endpoint: node.HTTPAuthEndpoint(), prov: noneAuth(secret), expectCall1Fail: true},
175+
176+
// claims of 5 seconds or more, older or newer, are not allowed
177+
{name: "ws too old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, -tooLong), expectDialFail: true},
178+
{name: "http too old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, -tooLong), expectCall1Fail: true},
179+
// 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"
180+
{name: "ws too new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, tooLong+requestDelay), expectDialFail: true},
181+
{name: "http too new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, tooLong+requestDelay), expectCall1Fail: true},
182+
183+
// Try offset the time, but stay just within bounds
184+
{name: "ws old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, -notTooLong)},
185+
{name: "http old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, -notTooLong)},
186+
{name: "ws new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(secret, notTooLong)},
187+
{name: "http new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(secret, notTooLong)},
188+
189+
// ws only authenticates on initial dial, then continues communication
190+
{name: "ws single auth", endpoint: node.WSAuthEndpoint(), prov: changingAuth(goodAuth, badAuth)},
191+
{name: "http call fail auth", endpoint: node.HTTPAuthEndpoint(), prov: changingAuth(goodAuth, badAuth), expectCall2Fail: true},
192+
{name: "http call fail time", endpoint: node.HTTPAuthEndpoint(), prov: changingAuth(goodAuth, offsetTimeAuth(secret, tooLong+requestDelay)), expectCall2Fail: true},
193+
}
194+
195+
for _, testCase := range testCases {
196+
t.Run(testCase.name, testCase.Run)
197+
}
198+
}
199+
200+
func noneAuth(secret [32]byte) rpc.HTTPAuth {
201+
return func(header http.Header) error {
202+
token := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{
203+
"iat": &jwt.NumericDate{Time: time.Now()},
204+
})
205+
s, err := token.SignedString(secret[:])
206+
if err != nil {
207+
return fmt.Errorf("failed to create JWT token: %w", err)
208+
}
209+
header.Set("Authorization", "Bearer "+s)
210+
return nil
211+
}
212+
}
213+
214+
func changingAuth(provs ...rpc.HTTPAuth) rpc.HTTPAuth {
215+
i := 0
216+
return func(header http.Header) error {
217+
i += 1
218+
if i > len(provs) {
219+
i = len(provs)
220+
}
221+
return provs[i-1](header)
222+
}
223+
}
224+
225+
func offsetTimeAuth(secret [32]byte, offset time.Duration) rpc.HTTPAuth {
226+
return func(header http.Header) error {
227+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
228+
"iat": &jwt.NumericDate{Time: time.Now().Add(offset)},
229+
})
230+
s, err := token.SignedString(secret[:])
231+
if err != nil {
232+
return fmt.Errorf("failed to create JWT token: %w", err)
233+
}
234+
header.Set("Authorization", "Bearer "+s)
235+
return nil
236+
}
237+
}

0 commit comments

Comments
 (0)