From 82e8d1142fb2fa53d841a7b427d368da2d2638b5 Mon Sep 17 00:00:00 2001 From: protolambda Date: Tue, 2 Aug 2022 17:08:56 +0200 Subject: [PATCH 01/11] node,rpc: auth RPC client util, auth rpc endpoint getters, auth rpc tests, enforce 256-bit secret Co-authored-by: Joshua Gutow --- node/config.go | 2 +- node/node.go | 13 +++ node/node_auth_test.go | 240 +++++++++++++++++++++++++++++++++++++++++ rpc/auth.go | 57 ++++++++++ rpc/client.go | 19 ++++ rpc/http.go | 33 ++++++ rpc/websocket.go | 38 +++++++ 7 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 node/node_auth_test.go create mode 100644 rpc/auth.go diff --git a/node/config.go b/node/config.go index 2047299fb5d7..49959d5ec5de 100644 --- a/node/config.go +++ b/node/config.go @@ -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"` } diff --git a/node/node.go b/node/node.go index 0a2b9eb83692..de875c566395 100644 --- a/node/node.go +++ b/node/node.go @@ -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 { diff --git a/node/node_auth_test.go b/node/node_auth_test.go new file mode 100644 index 000000000000..a813192f32fd --- /dev/null +++ b/node/node_auth_test.go @@ -0,0 +1,240 @@ +// 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 . + +package node + +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 TestAuthProvider func(header *http.Header) error + +func (fn TestAuthProvider) AddAuthHeader(header *http.Header) error { + return fn(header) +} + +type authTest struct { + name string + endpoint string + prov rpc.HeaderAuthProvider + expectDialFail bool + expectCall1Fail bool + expectCall2Fail bool +} + +func (at *authTest) Run(t *testing.T) { + ctx := context.Background() + cl, err := rpc.DialWithAuth(ctx, at.endpoint, 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"}, + } + 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 := rpc.NewJWTAuthProvider(secret) + var otherSecret [32]byte + if _, err := crand.Read(otherSecret[:]); err != nil { + t.Fatalf("failed to create jwt secret: %v", err) + } + badAuth := rpc.NewJWTAuthProvider(otherSecret) + noneAuth := TestAuthProvider(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.Add("Authorization", "Bearer "+s) + return nil + }) + offsetTimeAuth := func(offset time.Duration) TestAuthProvider { + 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.Add("Authorization", "Bearer "+s) + return nil + } + } + changingAuth := func(provs ...rpc.HeaderAuthProvider) TestAuthProvider { + i := 0 + return func(header *http.Header) error { + i += 1 + if i > len(provs) { + i = len(provs) + } + return provs[i-1].AddAuthHeader(header) + } + } + + 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 nil auth + {name: "ws nil auth provider", endpoint: node.WSAuthEndpoint(), prov: nil, expectDialFail: true}, + {name: "http nil auth provider", endpoint: node.HTTPAuthEndpoint(), prov: nil, expectDialFail: true}, + + // 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, expectDialFail: true}, + {name: "http none", endpoint: node.HTTPAuthEndpoint(), prov: noneAuth, expectCall1Fail: true}, + + // claims of 5 seconds or more, older or newer, are not allowed + {name: "ws too old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(-tooLong), expectDialFail: true}, + {name: "http too old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(-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(tooLong + requestDelay), expectDialFail: true}, + {name: "http too new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(tooLong + requestDelay), expectCall1Fail: true}, + + // Try offset the time, but stay just within bounds + {name: "ws old", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(-notTooLong)}, + {name: "http old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(-notTooLong)}, + {name: "ws new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(notTooLong)}, + {name: "http new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(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(tooLong+requestDelay)), expectCall2Fail: true}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, testCase.Run) + } +} diff --git a/rpc/auth.go b/rpc/auth.go new file mode 100644 index 000000000000..66381fbc6fde --- /dev/null +++ b/rpc/auth.go @@ -0,0 +1,57 @@ +// 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 . + +package rpc + +import ( + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// HeaderAuthProvider is an interface for adding JWT Bearer Tokens to HTTP/WS (on the initial upgrade) +// requests to authenticated APIs. +// See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md for details +// about the authentication scheme. +type HeaderAuthProvider interface { + // AddAuthHeader adds an up to date Authorization Bearer token field to the header + AddAuthHeader(header *http.Header) error +} + +type JWTAuthProvider struct { + secret [32]byte +} + +// NewJWTAuthProvider creates a new JWT Auth Provider. +// The secret MUST be 32 bytes (256 bits) as defined by the Engine-API authentication spec. +func NewJWTAuthProvider(jwtsecret [32]byte) *JWTAuthProvider { + return &JWTAuthProvider{secret: jwtsecret} +} + +// AddAuthHeader adds a JWT Authorization token to the header +func (p *JWTAuthProvider) AddAuthHeader(header *http.Header) error { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iat": &jwt.NumericDate{Time: time.Now()}, + }) + s, err := token.SignedString(p.secret[:]) + if err != nil { + return fmt.Errorf("failed to create JWT token: %w", err) + } + header.Add("Authorization", "Bearer "+s) + return nil +} diff --git a/rpc/client.go b/rpc/client.go index d3ce0297754c..305db32af0a8 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -186,6 +186,25 @@ func DialContext(ctx context.Context, rawurl string) (*Client, error) { } } +func DialWithAuth(ctx context.Context, rawurl string, auth HeaderAuthProvider) (*Client, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + switch u.Scheme { + case "http", "https": + return DialHTTPWithAuth(rawurl, auth) + case "ws", "wss": + return DialWebsocketWithAuth(ctx, rawurl, "", auth) + case "stdio": + return DialStdIO(ctx) + case "": + return DialIPC(ctx, rawurl) + default: + return nil, fmt.Errorf("no known transport for URL scheme %q", u.Scheme) + } +} + // ClientFromContext retrieves the client from the context, if any. This can be used to perform // 'reverse calls' in a handler method. func ClientFromContext(ctx context.Context) (*Client, bool) { diff --git a/rpc/http.go b/rpc/http.go index 9f4464957349..b7ce0682d838 100644 --- a/rpc/http.go +++ b/rpc/http.go @@ -45,6 +45,7 @@ type httpConn struct { closeCh chan interface{} mu sync.Mutex // protects headers headers http.Header + auth HeaderAuthProvider // authorization provider. Must be called on each request as token claims the current time } // httpConn implements ServerCodec, but it is treated specially by Client @@ -137,6 +138,33 @@ func DialHTTP(endpoint string) (*Client, error) { return DialHTTPWithClient(endpoint, new(http.Client)) } +// DialHTTPWithAuth creates a new authenticated RPC client that connects to an RPC server over HTTP. +func DialHTTPWithAuth(endpoint string, auth HeaderAuthProvider) (*Client, error) { + if auth == nil { + return nil, errors.New("cannot dial http endpoint with auth without auth-provider") + } + // Sanity check URL so we don't end up with a client that will fail every request. + _, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + client := new(http.Client) + initctx := context.Background() + headers := make(http.Header, 2) + headers.Set("accept", contentType) + headers.Set("content-type", contentType) + return newClient(initctx, func(context.Context) (ServerCodec, error) { + hc := &httpConn{ + client: client, + headers: headers, + url: endpoint, + closeCh: make(chan interface{}), + auth: auth, + } + return hc, nil + }) +} + func (c *Client) sendHTTP(ctx context.Context, op *requestOp, msg interface{}) error { hc := c.writeConn.(*httpConn) respBody, err := hc.doRequest(ctx, msg) @@ -186,6 +214,11 @@ func (hc *httpConn) doRequest(ctx context.Context, msg interface{}) (io.ReadClos hc.mu.Lock() req.Header = hc.headers.Clone() hc.mu.Unlock() + if hc.auth != nil { + if err := hc.auth.AddAuthHeader(&req.Header); err != nil { + return nil, err + } + } // do request resp, err := hc.client.Do(req) diff --git a/rpc/websocket.go b/rpc/websocket.go index 28380d8aa4ae..836d47284e3c 100644 --- a/rpc/websocket.go +++ b/rpc/websocket.go @@ -19,6 +19,7 @@ package rpc import ( "context" "encoding/base64" + "errors" "fmt" "net/http" "net/url" @@ -215,6 +216,43 @@ func DialWebsocket(ctx context.Context, endpoint, origin string) (*Client, error return DialWebsocketWithDialer(ctx, endpoint, origin, dialer) } +// DialWebsocketWithAuth creates a new RPC client that communicates with a JSON-RPC server +// that is listening on the given endpoint. It uses the HeaderAuthProvider to authenticate +// the initial HTTP connection/upgrade. +// +// The context is used for the initial connection establishment. It does not +// affect subsequent interactions with the client. +func DialWebsocketWithAuth(ctx context.Context, endpoint, origin string, auth HeaderAuthProvider) (*Client, error) { + if auth == nil { + return nil, errors.New("cannot dial websocket endpoint with auth without auth-provider") + } + endpoint, header, err := wsClientHeaders(endpoint, origin) + if err != nil { + return nil, err + } + dialer := websocket.Dialer{ + ReadBufferSize: wsReadBuffer, + WriteBufferSize: wsWriteBuffer, + WriteBufferPool: wsBufferPool, + } + return newClient(ctx, func(ctx context.Context) (ServerCodec, error) { + // every time we reconnect, we may use new authentication headers + dialHeader := header.Clone() + if err = auth.AddAuthHeader(&dialHeader); err != nil { + return nil, err + } + conn, resp, err := dialer.DialContext(ctx, endpoint, dialHeader) + if err != nil { + hErr := wsHandshakeError{err: err} + if resp != nil { + hErr.status = resp.Status + } + return nil, hErr + } + return newWebsocketCodec(conn, endpoint, dialHeader), nil + }) +} + func wsClientHeaders(endpoint, origin string) (string, http.Header, error) { endpointURL, err := url.Parse(endpoint) if err != nil { From 418b7d2dcaafce496066428cbfc9dd7f6d8833a4 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Thu, 25 Aug 2022 16:48:03 +0200 Subject: [PATCH 02/11] rpc: implement client dial options --- rpc/auth.go | 74 +++++++++++++++++++++++++++++++++++++++++ rpc/client.go | 46 +++++++++++++------------- rpc/http.go | 69 +++++++++++++++++--------------------- rpc/ipc.go | 8 +++-- rpc/stdio.go | 8 +++-- rpc/websocket.go | 86 +++++++++++++++++++++++++----------------------- 6 files changed, 183 insertions(+), 108 deletions(-) diff --git a/rpc/auth.go b/rpc/auth.go index 66381fbc6fde..a5bd7eb4a931 100644 --- a/rpc/auth.go +++ b/rpc/auth.go @@ -22,8 +22,82 @@ import ( "time" "github.com/golang-jwt/jwt/v4" + "github.com/gorilla/websocket" ) +// ClientOption is a configuration option for the RPC client. +type ClientOption interface { + applyOption(*clientConfig) +} + +type clientConfig struct { + httpClient *http.Client + httpHeaders http.Header + httpAuth HeaderAuthProvider + + wsDialer *websocket.Dialer +} + +func (cfg *clientConfig) initHeaders() { + if cfg.httpHeaders == nil { + cfg.httpHeaders = make(http.Header) + } +} + +func (cfg *clientConfig) setHeader(key, value string) { + cfg.initHeaders() + cfg.httpHeaders.Set(key, value) +} + +type optionFunc func(*clientConfig) + +func (fn optionFunc) applyOption(opt *clientConfig) { + fn(opt) +} + +// WithWebsocketDialer configures the websocket.Dialer used by the RPC client. +func WithWebsocketDialer(dialer websocket.Dialer) ClientOption { + return optionFunc(func(cfg *clientConfig) { + cfg.wsDialer = &dialer + }) +} + +// WithHeader configures HTTP headers set by the RPC client. Headers set using this option +// will be used for both HTTP and WebSocket connections. +func WithHeader(key, value string) ClientOption { + return optionFunc(func(cfg *clientConfig) { + cfg.initHeaders() + cfg.httpHeaders.Set(key, value) + }) +} + +// WithHeaders configures HTTP headers set by the RPC client. Headers set using this +// option will be used for both HTTP and WebSocket connections. +func WithHeaders(headers http.Header) ClientOption { + return optionFunc(func(cfg *clientConfig) { + cfg.initHeaders() + for k, vs := range headers { + cfg.httpHeaders[k] = vs + } + }) +} + +// WithHTTPClient configures the http.Client used by the RPC client. +func WithHTTPClient(c *http.Client) ClientOption { + return optionFunc(func(cfg *clientConfig) { + cfg.httpClient = c + }) +} + +// WithHTTPAuth configures HTTP request authentication. The given provider will be called +// whenever a request is made. Note that only one authentication provider can be active at +// any time. +func WithHTTPAuth(a HeaderAuthProvider) ClientOption { + return optionFunc(func(cfg *clientConfig) { + cfg.httpAuth = a + }) +} + // HeaderAuthProvider is an interface for adding JWT Bearer Tokens to HTTP/WS (on the initial upgrade) // requests to authenticated APIs. // See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md for details diff --git a/rpc/client.go b/rpc/client.go index 305db32af0a8..fe6fd0ab74ca 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "net/url" + "os" "reflect" "strconv" "sync/atomic" @@ -99,7 +100,7 @@ type Client struct { reqTimeout chan *requestOp // removes response IDs when call timeout expires } -type reconnectFunc func(ctx context.Context) (ServerCodec, error) +type reconnectFunc func(context.Context) (ServerCodec, error) type clientContextKey struct{} @@ -160,7 +161,7 @@ func (op *requestOp) wait(ctx context.Context, c *Client) (*jsonrpcMessage, erro // // The client reconnects automatically if the connection is lost. func Dial(rawurl string) (*Client, error) { - return DialContext(context.Background(), rawurl) + return DialOptions(context.Background(), rawurl) } // DialContext creates a new RPC client, just like Dial. @@ -168,41 +169,40 @@ func Dial(rawurl string) (*Client, error) { // The context is used to cancel or time out the initial connection establishment. It does // not affect subsequent interactions with the client. func DialContext(ctx context.Context, rawurl string) (*Client, error) { - u, err := url.Parse(rawurl) - if err != nil { - return nil, err - } - switch u.Scheme { - case "http", "https": - return DialHTTP(rawurl) - case "ws", "wss": - return DialWebsocket(ctx, rawurl, "") - case "stdio": - return DialStdIO(ctx) - case "": - return DialIPC(ctx, rawurl) - default: - return nil, fmt.Errorf("no known transport for URL scheme %q", u.Scheme) - } + return DialOptions(ctx, rawurl) } -func DialWithAuth(ctx context.Context, rawurl string, auth HeaderAuthProvider) (*Client, error) { +// DialContext creates a new RPC client. +func DialOptions(ctx context.Context, rawurl string, options ...ClientOption) (*Client, error) { u, err := url.Parse(rawurl) if err != nil { return nil, err } + + cfg := new(clientConfig) + for _, opt := range options { + opt.applyOption(cfg) + } + + var reconnect reconnectFunc switch u.Scheme { case "http", "https": - return DialHTTPWithAuth(rawurl, auth) + reconnect = newClientTransportHTTP(rawurl, cfg) case "ws", "wss": - return DialWebsocketWithAuth(ctx, rawurl, "", auth) + rc, err := newClientTransportWS(rawurl, cfg) + if err != nil { + return nil, err + } + reconnect = rc case "stdio": - return DialStdIO(ctx) + reconnect = newClientTransportIO(os.Stdin, os.Stdout) case "": - return DialIPC(ctx, rawurl) + reconnect = newClientTransportIPC(rawurl) default: return nil, fmt.Errorf("no known transport for URL scheme %q", u.Scheme) } + + return newClient(ctx, reconnect) } // ClientFromContext retrieves the client from the context, if any. This can be used to perform diff --git a/rpc/http.go b/rpc/http.go index b7ce0682d838..e83e4fa1a015 100644 --- a/rpc/http.go +++ b/rpc/http.go @@ -109,8 +109,15 @@ var DefaultHTTPTimeouts = HTTPTimeouts{ IdleTimeout: 120 * time.Second, } +// DialHTTP creates a new RPC client that connects to an RPC server over HTTP. +func DialHTTP(endpoint string) (*Client, error) { + return DialHTTPWithClient(endpoint, new(http.Client)) +} + // DialHTTPWithClient creates a new RPC client that connects to an RPC server over HTTP // using the provided HTTP Client. +// +// Deprecated: use DialOptions and the WithHTTPClient option. func DialHTTPWithClient(endpoint string, client *http.Client) (*Client, error) { // Sanity check URL so we don't end up with a client that will fail every request. _, err := url.Parse(endpoint) @@ -118,51 +125,35 @@ func DialHTTPWithClient(endpoint string, client *http.Client) (*Client, error) { return nil, err } - initctx := context.Background() - headers := make(http.Header, 2) - headers.Set("accept", contentType) - headers.Set("content-type", contentType) - return newClient(initctx, func(context.Context) (ServerCodec, error) { - hc := &httpConn{ - client: client, - headers: headers, - url: endpoint, - closeCh: make(chan interface{}), - } - return hc, nil - }) + var cfg clientConfig + fn := newClientTransportHTTP(endpoint, &cfg) + return newClient(context.Background(), fn) } -// DialHTTP creates a new RPC client that connects to an RPC server over HTTP. -func DialHTTP(endpoint string) (*Client, error) { - return DialHTTPWithClient(endpoint, new(http.Client)) -} +func newClientTransportHTTP(endpoint string, cfg *clientConfig) reconnectFunc { + headers := make(http.Header, 2+len(cfg.httpHeaders)) + headers.Set("accept", contentType) + headers.Set("content-type", contentType) + for key, values := range cfg.httpHeaders { + headers[key] = values + } -// DialHTTPWithAuth creates a new authenticated RPC client that connects to an RPC server over HTTP. -func DialHTTPWithAuth(endpoint string, auth HeaderAuthProvider) (*Client, error) { - if auth == nil { - return nil, errors.New("cannot dial http endpoint with auth without auth-provider") + client := cfg.httpClient + if client == nil { + client = new(http.Client) } - // Sanity check URL so we don't end up with a client that will fail every request. - _, err := url.Parse(endpoint) - if err != nil { - return nil, err + + hc := &httpConn{ + client: client, + headers: headers, + url: endpoint, + auth: cfg.httpAuth, + closeCh: make(chan interface{}), } - client := new(http.Client) - initctx := context.Background() - headers := make(http.Header, 2) - headers.Set("accept", contentType) - headers.Set("content-type", contentType) - return newClient(initctx, func(context.Context) (ServerCodec, error) { - hc := &httpConn{ - client: client, - headers: headers, - url: endpoint, - closeCh: make(chan interface{}), - auth: auth, - } + + return func(ctx context.Context) (ServerCodec, error) { return hc, nil - }) + } } func (c *Client) sendHTTP(ctx context.Context, op *requestOp, msg interface{}) error { diff --git a/rpc/ipc.go b/rpc/ipc.go index 07a211c6277c..d9e0de62e877 100644 --- a/rpc/ipc.go +++ b/rpc/ipc.go @@ -46,11 +46,15 @@ func (s *Server) ServeListener(l net.Listener) error { // The context is used for the initial connection establishment. It does not // affect subsequent interactions with the client. func DialIPC(ctx context.Context, endpoint string) (*Client, error) { - return newClient(ctx, func(ctx context.Context) (ServerCodec, error) { + return newClient(ctx, newClientTransportIPC(endpoint)) +} + +func newClientTransportIPC(endpoint string) reconnectFunc { + return func(ctx context.Context) (ServerCodec, error) { conn, err := newIPCConnection(ctx, endpoint) if err != nil { return nil, err } return NewCodec(conn), err - }) + } } diff --git a/rpc/stdio.go b/rpc/stdio.go index be2bab1c98bd..ae32db26ef1c 100644 --- a/rpc/stdio.go +++ b/rpc/stdio.go @@ -32,12 +32,16 @@ func DialStdIO(ctx context.Context) (*Client, error) { // DialIO creates a client which uses the given IO channels func DialIO(ctx context.Context, in io.Reader, out io.Writer) (*Client, error) { - return newClient(ctx, func(_ context.Context) (ServerCodec, error) { + return newClient(ctx, newClientTransportIO(in, out)) +} + +func newClientTransportIO(in io.Reader, out io.Writer) reconnectFunc { + return func(context.Context) (ServerCodec, error) { return NewCodec(stdioConn{ in: in, out: out, }), nil - }) + } } type stdioConn struct { diff --git a/rpc/websocket.go b/rpc/websocket.go index 836d47284e3c..8d50fab66f33 100644 --- a/rpc/websocket.go +++ b/rpc/websocket.go @@ -19,7 +19,6 @@ package rpc import ( "context" "encoding/base64" - "errors" "fmt" "net/http" "net/url" @@ -182,24 +181,23 @@ func parseOriginURL(origin string) (string, string, string, error) { return scheme, hostname, port, nil } -// DialWebsocketWithDialer creates a new RPC client that communicates with a JSON-RPC server -// that is listening on the given endpoint using the provided dialer. +// DialWebsocketWithDialer creates a new RPC client using WebSocket. +// +// The context is used for the initial connection establishment. It does not +// affect subsequent interactions with the client. +// +// Deprecated: use DialOptions and the WithWebsocketDialer option. func DialWebsocketWithDialer(ctx context.Context, endpoint, origin string, dialer websocket.Dialer) (*Client, error) { - endpoint, header, err := wsClientHeaders(endpoint, origin) + cfg := new(clientConfig) + cfg.wsDialer = &dialer + if origin != "" { + cfg.setHeader("origin", origin) + } + connect, err := newClientTransportWS(endpoint, cfg) if err != nil { return nil, err } - return newClient(ctx, func(ctx context.Context) (ServerCodec, error) { - conn, resp, err := dialer.DialContext(ctx, endpoint, header) - if err != nil { - hErr := wsHandshakeError{err: err} - if resp != nil { - hErr.status = resp.Status - } - return nil, hErr - } - return newWebsocketCodec(conn, endpoint, header), nil - }) + return newClient(ctx, connect) } // DialWebsocket creates a new RPC client that communicates with a JSON-RPC server @@ -208,40 +206,43 @@ func DialWebsocketWithDialer(ctx context.Context, endpoint, origin string, diale // The context is used for the initial connection establishment. It does not // affect subsequent interactions with the client. func DialWebsocket(ctx context.Context, endpoint, origin string) (*Client, error) { - dialer := websocket.Dialer{ - ReadBufferSize: wsReadBuffer, - WriteBufferSize: wsWriteBuffer, - WriteBufferPool: wsBufferPool, + cfg := new(clientConfig) + if origin != "" { + cfg.setHeader("origin", origin) + } + connect, err := newClientTransportWS(endpoint, cfg) + if err != nil { + return nil, err } - return DialWebsocketWithDialer(ctx, endpoint, origin, dialer) + return newClient(ctx, connect) } -// DialWebsocketWithAuth creates a new RPC client that communicates with a JSON-RPC server -// that is listening on the given endpoint. It uses the HeaderAuthProvider to authenticate -// the initial HTTP connection/upgrade. -// -// The context is used for the initial connection establishment. It does not -// affect subsequent interactions with the client. -func DialWebsocketWithAuth(ctx context.Context, endpoint, origin string, auth HeaderAuthProvider) (*Client, error) { - if auth == nil { - return nil, errors.New("cannot dial websocket endpoint with auth without auth-provider") +func newClientTransportWS(endpoint string, cfg *clientConfig) (reconnectFunc, error) { + dialer := cfg.wsDialer + if dialer == nil { + dialer = &websocket.Dialer{ + ReadBufferSize: wsReadBuffer, + WriteBufferSize: wsWriteBuffer, + WriteBufferPool: wsBufferPool, + } } - endpoint, header, err := wsClientHeaders(endpoint, origin) + + dialURL, header, err := wsClientHeaders(endpoint, "") if err != nil { return nil, err } - dialer := websocket.Dialer{ - ReadBufferSize: wsReadBuffer, - WriteBufferSize: wsWriteBuffer, - WriteBufferPool: wsBufferPool, + for key, values := range cfg.httpHeaders { + header[key] = values } - return newClient(ctx, func(ctx context.Context) (ServerCodec, error) { - // every time we reconnect, we may use new authentication headers - dialHeader := header.Clone() - if err = auth.AddAuthHeader(&dialHeader); err != nil { - return nil, err + + connect := func(ctx context.Context) (ServerCodec, error) { + header := header.Clone() + if cfg.httpAuth != nil { + if err := cfg.httpAuth.AddAuthHeader(&header); err != nil { + return nil, err + } } - conn, resp, err := dialer.DialContext(ctx, endpoint, dialHeader) + conn, resp, err := dialer.DialContext(ctx, dialURL, header) if err != nil { hErr := wsHandshakeError{err: err} if resp != nil { @@ -249,8 +250,9 @@ func DialWebsocketWithAuth(ctx context.Context, endpoint, origin string, auth He } return nil, hErr } - return newWebsocketCodec(conn, endpoint, dialHeader), nil - }) + return newWebsocketCodec(conn, dialURL, header), nil + } + return connect, nil } func wsClientHeaders(endpoint, origin string) (string, http.Header, error) { From c6222ac1552420fdd3bfede9c96a43091308c3ef Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Wed, 31 Aug 2022 21:08:54 +0200 Subject: [PATCH 03/11] rpc: add check for nil auth provider --- rpc/auth.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rpc/auth.go b/rpc/auth.go index a5bd7eb4a931..0eb89c2da536 100644 --- a/rpc/auth.go +++ b/rpc/auth.go @@ -93,6 +93,9 @@ func WithHTTPClient(c *http.Client) ClientOption { // whenever a request is made. Note that only one authentication provider can be active at // any time. func WithHTTPAuth(a HeaderAuthProvider) ClientOption { + if a == nil { + panic("nil auth") + } return optionFunc(func(cfg *clientConfig) { cfg.httpAuth = a }) From 0a3e2f867f909c3eee657fb4700d81c451fc6235 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Wed, 31 Aug 2022 21:09:06 +0200 Subject: [PATCH 04/11] node: update JWT auth test --- node/node_auth_test.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/node/node_auth_test.go b/node/node_auth_test.go index a813192f32fd..b139a729a946 100644 --- a/node/node_auth_test.go +++ b/node/node_auth_test.go @@ -54,7 +54,7 @@ type authTest struct { func (at *authTest) Run(t *testing.T) { ctx := context.Background() - cl, err := rpc.DialWithAuth(ctx, at.endpoint, at.prov) + cl, err := rpc.DialOptions(ctx, at.endpoint, rpc.WithHTTPAuth(at.prov)) if at.expectDialFail { if err == nil { t.Fatal("expected initial dial to fail") @@ -65,6 +65,7 @@ func (at *authTest) Run(t *testing.T) { 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 { @@ -80,6 +81,7 @@ func (at *authTest) Run(t *testing.T) { 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 { @@ -203,10 +205,6 @@ func TestAuthEndpoints(t *testing.T) { {name: "ws good", endpoint: node.WSAuthEndpoint(), prov: goodAuth, expectCall1Fail: false}, {name: "http good", endpoint: node.HTTPAuthEndpoint(), prov: goodAuth, expectCall1Fail: false}, - // Try nil auth - {name: "ws nil auth provider", endpoint: node.WSAuthEndpoint(), prov: nil, expectDialFail: true}, - {name: "http nil auth provider", endpoint: node.HTTPAuthEndpoint(), prov: nil, expectDialFail: true}, - // 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 From c6c2cf846fd3bde5690387f8a6ccbc11f1352aee Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Wed, 31 Aug 2022 21:12:47 +0200 Subject: [PATCH 05/11] node: refactor auth test --- node/node_auth_test.go | 95 ++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/node/node_auth_test.go b/node/node_auth_test.go index b139a729a946..1e5945093385 100644 --- a/node/node_auth_test.go +++ b/node/node_auth_test.go @@ -161,40 +161,6 @@ func TestAuthEndpoints(t *testing.T) { t.Fatalf("failed to create jwt secret: %v", err) } badAuth := rpc.NewJWTAuthProvider(otherSecret) - noneAuth := TestAuthProvider(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.Add("Authorization", "Bearer "+s) - return nil - }) - offsetTimeAuth := func(offset time.Duration) TestAuthProvider { - 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.Add("Authorization", "Bearer "+s) - return nil - } - } - changingAuth := func(provs ...rpc.HeaderAuthProvider) TestAuthProvider { - i := 0 - return func(header *http.Header) error { - i += 1 - if i > len(provs) { - i = len(provs) - } - return provs[i-1].AddAuthHeader(header) - } - } notTooLong := time.Second * 57 tooLong := time.Second * 60 @@ -210,29 +176,68 @@ func TestAuthEndpoints(t *testing.T) { {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, expectDialFail: true}, - {name: "http none", endpoint: node.HTTPAuthEndpoint(), prov: noneAuth, expectCall1Fail: true}, + {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(-tooLong), expectDialFail: true}, - {name: "http too old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(-tooLong), expectCall1Fail: true}, + {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(tooLong + requestDelay), expectDialFail: true}, - {name: "http too new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(tooLong + requestDelay), expectCall1Fail: true}, + {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(-notTooLong)}, - {name: "http old", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(-notTooLong)}, - {name: "ws new", endpoint: node.WSAuthEndpoint(), prov: offsetTimeAuth(notTooLong)}, - {name: "http new", endpoint: node.HTTPAuthEndpoint(), prov: offsetTimeAuth(notTooLong)}, + {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(tooLong+requestDelay)), 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) TestAuthProvider { + 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.Add("Authorization", "Bearer "+s) + return nil + } +} + +func changingAuth(provs ...rpc.HeaderAuthProvider) TestAuthProvider { + i := 0 + return func(header *http.Header) error { + i += 1 + if i > len(provs) { + i = len(provs) + } + return provs[i-1].AddAuthHeader(header) + } +} + +func offsetTimeAuth(secret [32]byte, offset time.Duration) TestAuthProvider { + 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.Add("Authorization", "Bearer "+s) + return nil + } +} From 5ace82b1768be6a558e656d62be124cbe153dfa1 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Wed, 31 Aug 2022 21:37:26 +0200 Subject: [PATCH 06/11] rpc: remove jwt support --- rpc/auth.go | 39 +++------------------------------------ rpc/http.go | 4 ++-- rpc/websocket.go | 2 +- 3 files changed, 6 insertions(+), 39 deletions(-) diff --git a/rpc/auth.go b/rpc/auth.go index 0eb89c2da536..908f8140c87b 100644 --- a/rpc/auth.go +++ b/rpc/auth.go @@ -17,11 +17,8 @@ package rpc import ( - "fmt" "net/http" - "time" - "github.com/golang-jwt/jwt/v4" "github.com/gorilla/websocket" ) @@ -33,7 +30,7 @@ type ClientOption interface { type clientConfig struct { httpClient *http.Client httpHeaders http.Header - httpAuth HeaderAuthProvider + httpAuth HTTPAuth wsDialer *websocket.Dialer } @@ -92,7 +89,7 @@ func WithHTTPClient(c *http.Client) ClientOption { // WithHTTPAuth configures HTTP request authentication. The given provider will be called // whenever a request is made. Note that only one authentication provider can be active at // any time. -func WithHTTPAuth(a HeaderAuthProvider) ClientOption { +func WithHTTPAuth(a HTTPAuth) ClientOption { if a == nil { panic("nil auth") } @@ -101,34 +98,4 @@ func WithHTTPAuth(a HeaderAuthProvider) ClientOption { }) } -// HeaderAuthProvider is an interface for adding JWT Bearer Tokens to HTTP/WS (on the initial upgrade) -// requests to authenticated APIs. -// See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md for details -// about the authentication scheme. -type HeaderAuthProvider interface { - // AddAuthHeader adds an up to date Authorization Bearer token field to the header - AddAuthHeader(header *http.Header) error -} - -type JWTAuthProvider struct { - secret [32]byte -} - -// NewJWTAuthProvider creates a new JWT Auth Provider. -// The secret MUST be 32 bytes (256 bits) as defined by the Engine-API authentication spec. -func NewJWTAuthProvider(jwtsecret [32]byte) *JWTAuthProvider { - return &JWTAuthProvider{secret: jwtsecret} -} - -// AddAuthHeader adds a JWT Authorization token to the header -func (p *JWTAuthProvider) AddAuthHeader(header *http.Header) error { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "iat": &jwt.NumericDate{Time: time.Now()}, - }) - s, err := token.SignedString(p.secret[:]) - if err != nil { - return fmt.Errorf("failed to create JWT token: %w", err) - } - header.Add("Authorization", "Bearer "+s) - return nil -} +type HTTPAuth func(h http.Header) error diff --git a/rpc/http.go b/rpc/http.go index e83e4fa1a015..df4fdd56eca1 100644 --- a/rpc/http.go +++ b/rpc/http.go @@ -45,7 +45,7 @@ type httpConn struct { closeCh chan interface{} mu sync.Mutex // protects headers headers http.Header - auth HeaderAuthProvider // authorization provider. Must be called on each request as token claims the current time + auth HTTPAuth } // httpConn implements ServerCodec, but it is treated specially by Client @@ -206,7 +206,7 @@ func (hc *httpConn) doRequest(ctx context.Context, msg interface{}) (io.ReadClos req.Header = hc.headers.Clone() hc.mu.Unlock() if hc.auth != nil { - if err := hc.auth.AddAuthHeader(&req.Header); err != nil { + if err := hc.auth(req.Header); err != nil { return nil, err } } diff --git a/rpc/websocket.go b/rpc/websocket.go index 8d50fab66f33..f2a923446cac 100644 --- a/rpc/websocket.go +++ b/rpc/websocket.go @@ -238,7 +238,7 @@ func newClientTransportWS(endpoint string, cfg *clientConfig) (reconnectFunc, er connect := func(ctx context.Context) (ServerCodec, error) { header := header.Clone() if cfg.httpAuth != nil { - if err := cfg.httpAuth.AddAuthHeader(&header); err != nil { + if err := cfg.httpAuth(header); err != nil { return nil, err } } From b2f790bd855a8ee29320234feb6b43014541ab00 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Wed, 31 Aug 2022 21:39:13 +0200 Subject: [PATCH 07/11] node: add jwt client auth support (moved from package rpc) --- node/jwt_auth.go | 45 ++++++++++++++++++++++++++++++++++++++++++ node/node_auth_test.go | 30 +++++++++++----------------- 2 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 node/jwt_auth.go diff --git a/node/jwt_auth.go b/node/jwt_auth.go new file mode 100644 index 000000000000..b72d62d54405 --- /dev/null +++ b/node/jwt_auth.go @@ -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 . + +package node + +import ( + "fmt" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/rpc" + "github.com/golang-jwt/jwt/v4" +) + +// NewJWTAuthProvider creates an 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 NewJWTAuthProvider(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 + } +} diff --git a/node/node_auth_test.go b/node/node_auth_test.go index 1e5945093385..87974db8028a 100644 --- a/node/node_auth_test.go +++ b/node/node_auth_test.go @@ -37,16 +37,10 @@ func (ta helloRPC) HelloWorld() (string, error) { return string(ta), nil } -type TestAuthProvider func(header *http.Header) error - -func (fn TestAuthProvider) AddAuthHeader(header *http.Header) error { - return fn(header) -} - type authTest struct { name string endpoint string - prov rpc.HeaderAuthProvider + prov rpc.HTTPAuth expectDialFail bool expectCall1Fail bool expectCall2Fail bool @@ -155,12 +149,12 @@ func TestAuthEndpoints(t *testing.T) { t.Fatalf("expected http and auth-http endpoints to be different, got: %q and %q", a, b) } - goodAuth := rpc.NewJWTAuthProvider(secret) + goodAuth := NewJWTAuthProvider(secret) var otherSecret [32]byte if _, err := crand.Read(otherSecret[:]); err != nil { t.Fatalf("failed to create jwt secret: %v", err) } - badAuth := rpc.NewJWTAuthProvider(otherSecret) + badAuth := NewJWTAuthProvider(otherSecret) notTooLong := time.Second * 57 tooLong := time.Second * 60 @@ -203,8 +197,8 @@ func TestAuthEndpoints(t *testing.T) { } } -func noneAuth(secret [32]byte) TestAuthProvider { - return func(header *http.Header) error { +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()}, }) @@ -212,24 +206,24 @@ func noneAuth(secret [32]byte) TestAuthProvider { if err != nil { return fmt.Errorf("failed to create JWT token: %w", err) } - header.Add("Authorization", "Bearer "+s) + header.Set("Authorization", "Bearer "+s) return nil } } -func changingAuth(provs ...rpc.HeaderAuthProvider) TestAuthProvider { +func changingAuth(provs ...rpc.HTTPAuth) rpc.HTTPAuth { i := 0 - return func(header *http.Header) error { + return func(header http.Header) error { i += 1 if i > len(provs) { i = len(provs) } - return provs[i-1].AddAuthHeader(header) + return provs[i-1](header) } } -func offsetTimeAuth(secret [32]byte, offset time.Duration) TestAuthProvider { - return func(header *http.Header) error { +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)}, }) @@ -237,7 +231,7 @@ func offsetTimeAuth(secret [32]byte, offset time.Duration) TestAuthProvider { if err != nil { return fmt.Errorf("failed to create JWT token: %w", err) } - header.Add("Authorization", "Bearer "+s) + header.Set("Authorization", "Bearer "+s) return nil } } From 6b1f5f69a5d1f9dd26f5ad8172fb0de40ec946d1 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Fri, 2 Sep 2022 15:01:20 +0200 Subject: [PATCH 08/11] node: rename JWT auth constructor --- node/jwt_auth.go | 8 ++++---- node/node_auth_test.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/node/jwt_auth.go b/node/jwt_auth.go index b72d62d54405..d4f8193ca7f2 100644 --- a/node/jwt_auth.go +++ b/node/jwt_auth.go @@ -25,12 +25,12 @@ import ( "github.com/golang-jwt/jwt/v4" ) -// NewJWTAuthProvider creates an authentication provider that uses JWT. +// 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. // -// 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 +// See https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md // for more details about this authentication scheme. -func NewJWTAuthProvider(jwtsecret [32]byte) rpc.HTTPAuth { +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()}, diff --git a/node/node_auth_test.go b/node/node_auth_test.go index 87974db8028a..597cd8531f79 100644 --- a/node/node_auth_test.go +++ b/node/node_auth_test.go @@ -149,12 +149,12 @@ func TestAuthEndpoints(t *testing.T) { t.Fatalf("expected http and auth-http endpoints to be different, got: %q and %q", a, b) } - goodAuth := NewJWTAuthProvider(secret) + goodAuth := NewJWTAuth(secret) var otherSecret [32]byte if _, err := crand.Read(otherSecret[:]); err != nil { t.Fatalf("failed to create jwt secret: %v", err) } - badAuth := NewJWTAuthProvider(otherSecret) + badAuth := NewJWTAuth(otherSecret) notTooLong := time.Second * 57 tooLong := time.Second * 60 From 2630b9dbcfaecab471c050fcaf2a80d159524227 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Fri, 2 Sep 2022 15:16:26 +0200 Subject: [PATCH 09/11] rpc: rename client options file --- rpc/{auth.go => client_opt.go} | 0 rpc/client_opt_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+) rename rpc/{auth.go => client_opt.go} (100%) create mode 100644 rpc/client_opt_test.go diff --git a/rpc/auth.go b/rpc/client_opt.go similarity index 100% rename from rpc/auth.go rename to rpc/client_opt.go diff --git a/rpc/client_opt_test.go b/rpc/client_opt_test.go new file mode 100644 index 000000000000..f406582526dd --- /dev/null +++ b/rpc/client_opt_test.go @@ -0,0 +1,25 @@ +package rpc_test + +import ( + "context" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/rpc" +) + +// This example configures a HTTP-based RPC client with two options - one setting the +// overall request timeout, the other adding a custom HTTP header to all requests. +func ExampleClientOptions() { + tokenHeader := rpc.WithHeader("x-token", "foo") + httpClient := rpc.WithHTTPClient(&http.Client{ + Timeout: 10 * time.Second, + }) + + ctx := context.Background() + c, err := rpc.DialOptions(ctx, "http://rpc.example.com", httpClient, tokenHeader) + if err != nil { + panic(err) + } + c.Close() +} From 278c580a5705d276f1eee564292325c84e8ae9c7 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Fri, 2 Sep 2022 15:30:01 +0200 Subject: [PATCH 10/11] rpc: improve docs --- rpc/client.go | 16 ++++++++++++---- rpc/client_opt.go | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/rpc/client.go b/rpc/client.go index fe6fd0ab74ca..8288f976ebeb 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -154,12 +154,14 @@ func (op *requestOp) wait(ctx context.Context, c *Client) (*jsonrpcMessage, erro // // The currently supported URL schemes are "http", "https", "ws" and "wss". If rawurl is a // file name with no URL scheme, a local socket connection is established using UNIX -// domain sockets on supported platforms and named pipes on Windows. If you want to -// configure transport options, use DialHTTP, DialWebsocket or DialIPC instead. +// domain sockets on supported platforms and named pipes on Windows. +// +// If you want to further configure the transport, use DialOptions instead of this +// function. // // For websocket connections, the origin is set to the local host name. // -// The client reconnects automatically if the connection is lost. +// The client reconnects automatically when the connection is lost. func Dial(rawurl string) (*Client, error) { return DialOptions(context.Background(), rawurl) } @@ -172,7 +174,13 @@ func DialContext(ctx context.Context, rawurl string) (*Client, error) { return DialOptions(ctx, rawurl) } -// DialContext creates a new RPC client. +// DialOptions creates a new RPC client for the given URL. You can supply any of the +// pre-defined client options to configure the underlying transport. +// +// The context is used to cancel or time out the initial connection establishment. It does +// not affect subsequent interactions with the client. +// +// The client reconnects automatically when the connection is lost. func DialOptions(ctx context.Context, rawurl string, options ...ClientOption) (*Client, error) { u, err := url.Parse(rawurl) if err != nil { diff --git a/rpc/client_opt.go b/rpc/client_opt.go index 908f8140c87b..5ad7c22b3ce7 100644 --- a/rpc/client_opt.go +++ b/rpc/client_opt.go @@ -98,4 +98,9 @@ func WithHTTPAuth(a HTTPAuth) ClientOption { }) } +// A HTTPAuth function is called by the client whenever a HTTP request is sent. +// The function must be safe for concurrent use. +// +// Usually, HTTPAuth functions will call h.Set("authorization", "...") to add +// auth information to the request. type HTTPAuth func(h http.Header) error From 8c2013bff8618b1a35a767e8def759e189fb8c35 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Fri, 2 Sep 2022 15:55:37 +0200 Subject: [PATCH 11/11] rpc: rename example --- rpc/client_opt_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rpc/client_opt_test.go b/rpc/client_opt_test.go index f406582526dd..d7cc2572a776 100644 --- a/rpc/client_opt_test.go +++ b/rpc/client_opt_test.go @@ -10,7 +10,7 @@ import ( // This example configures a HTTP-based RPC client with two options - one setting the // overall request timeout, the other adding a custom HTTP header to all requests. -func ExampleClientOptions() { +func ExampleDialOptions() { tokenHeader := rpc.WithHeader("x-token", "foo") httpClient := rpc.WithHTTPClient(&http.Client{ Timeout: 10 * time.Second,