Skip to content

Commit

Permalink
op-txproxy: external validating proxy for conditional transactions (#42)
Browse files Browse the repository at this point in the history
* txpool svc

* change mod github path

* tag-tool

* codeowners
  • Loading branch information
hamdiallam authored Sep 30, 2024
1 parent 88521be commit dff24e9
Show file tree
Hide file tree
Showing 17 changed files with 1,205 additions and 0 deletions.
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ workflows:
mapping: |
op-conductor-mon/.* run-build-op-conductor-mon true
op-signer/.* run-build-op-signer true
op-txproxy/.* run-build-op-txproxy true
op-ufm/.* run-build-op-ufm true
proxyd/.* run-build-proxyd true
.circleci/.* run-all true
Expand Down
19 changes: 19 additions & 0 deletions .circleci/continue_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ parameters:
run-build-op-signer:
type: boolean
default: false
run-build-op-txproxy:
type: boolean
default: false
run-build-op-ufm:
type: boolean
default: false
Expand Down Expand Up @@ -434,6 +437,22 @@ workflows:
docker_name: op-signer
docker_tags: <<pipeline.git.revision>>,<<pipeline.git.branch>>
docker_context: .
op-txproxy:
when:
or: [<< pipeline.parameters.run-build-op-txproxy >>, << pipeline.parameters.run-all >>]
jobs:
- go-lint:
name: op-txproxy-lint
module: op-txproxy
- go-test:
name: op-txproxy-tests
module: op-txproxy
- docker-build:
name: op-txproxy-docker-build
docker_file: op-txproxy/Dockerfile
docker_name: op-txproxy
docker_tags: <<pipeline.git.revision>>,<<pipeline.git.branch>>
docker_context: .
op-ufm:
when:
or: [<< pipeline.parameters.run-build-op-ufm >>, << pipeline.parameters.run-all >>]
Expand Down
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
* @ethereum-optimism/infra-reviewers

/op-txproxy @ethereum-optimism/devxpod
1 change: 1 addition & 0 deletions op-txproxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bin
18 changes: 18 additions & 0 deletions op-txproxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM golang:1.21.3-alpine3.18 as builder

COPY ./op-txproxy /app

WORKDIR /app
RUN apk --no-cache add make jq bash git alpine-sdk
RUN make build

FROM alpine:3.18
RUN apk --no-cache add ca-certificates

RUN addgroup -S app && adduser -S app -G app
USER app
WORKDIR /app

COPY --from=builder /app/bin/op-txproxy /app

ENTRYPOINT ["/app/op-txproxy"]
28 changes: 28 additions & 0 deletions op-txproxy/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
GIT_COMMIT := $(shell git rev-parse HEAD)
GIT_DATE := $(shell git show -s --format='%ct')

LDFLAGSSTRING +=-X main.GitCommit=$(GIT_COMMIT)
LDFLAGSSTRING +=-X main.GitDate=$(GITDGIT_DATEATE)
LDFLAGSSTRING +=-X main.Version=$(OP_CONDUCTOR_MON_VERSION)
LDFLAGS := -ldflags "$(LDFLAGSSTRING)"


all: build

build:
env GO111MODULE=on go build -v $(LDFLAGS) -o ./bin/op-txproxy ./cmd

clean:
rm ./bin/op-txproxy

test:
go test -v ./...

lint:
golangci-lint run ./...

.PHONY: \
build \
clean \
test \
lint
36 changes: 36 additions & 0 deletions op-txproxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# op-txproxy

A supplemental passthrough proxy for some execution engine endpoints. This proxy does not forward all rpc traffic and only exposes a specific set of endpoints.
Operationally, the public ingress proxy should only re-route requests for these endpoints.

```mermaid
stateDiagram-v2
proxyd --> txproxy: intercepted methods
proxyd --> backend: unintercepted methods
txproxy --> backend
```

## Setup
Install go 1.21
```
make build
./bin/op-txproxy --help
```

## Endpoints

### eth_sendRawTransactionConditional

An outcome of how to integrate this [spec](https://notes.ethereum.org/@yoav/SkaX2lS9j) safely for permissionless 4337 bundler participation. This solution in the design doc [proposal](https://github.com/ethereum-optimism/design-docs/blob/main/ecosystem/sendRawTransactionConditional/proposal.md)
requires a validating proxy that can be horizontally scaled and pre-emptively reject invalid conditional transaction. The implemented endpoint covers
these objectives:
1. **Auth**. preemptively put in place to enable a variety of auth policies (allowlist, rate limits, etc).

The caller authenticates themselves with any valid ECDSA-secp256k1 key, like an Ethereum key. The computed signature is over the [EIP-191](https://eips.ethereum.org/EIPS/eip-191) hash of the request body (up to the 5MB request body limit).

With the signature and signing address, the request is authenticated via the `X-Optimism-Signature` header of the request with the value `<public key address>: <signature>`.

2. **Rate Limits**. global rate limits on the endpoint are applied here.
2. **Rejection Switch**. this proxy can be rolled with a flag/env switch to reject conditional transaction without needing to interrupt the execution engine.
3. **Basic Validation**. stateless validation is done in the endpoint to reject invalid conditional transactions and apply additional restricts on the usage (only 4337 entrypoint tx target support).
4. **Metrics**. performance of this endpoint can be observed in order to inform adjustments to rate limits, shutoff, or auth policies to implement.
101 changes: 101 additions & 0 deletions op-txproxy/auth_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package op_txproxy

import (
"bytes"
"context"
"io"
"net/http"
"strings"

"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)

var (
defaultBodyLimit = 5 * 1024 * 1024 // default in op-geth

DefaultAuthHeaderKey = "X-Optimism-Signature"
)

type authHandler struct {
headerKey string
next http.Handler
}

// This middleware detects when authentication information is present on the request. If
// so, it will validate and set the caller in the request context. It does not reject
// if authentication information is missing. It is up to the request handler to do so via
// the missing `AuthContext`
// - NOTE: only up to the default body limit (5MB) is read when constructing the text hash
// that is signed over by the caller
func AuthMiddleware(headerKey string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return &authHandler{headerKey, next}
}
}

type authContextKey struct{}

type AuthContext struct {
Caller common.Address
}

// ServeHTTP serves JSON-RPC requests over HTTP, implements http.Handler
func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get(h.headerKey)
if authHeader == "" {
h.next.ServeHTTP(w, r)
return
}
authElems := strings.Split(authHeader, ":")
if len(authElems) != 2 {
http.Error(w, "misformatted auth header", http.StatusBadRequest)
return
}

if r.Body == nil {
// edge case from unit tests
r.Body = io.NopCloser(bytes.NewBuffer(nil))
}

// Since this middleware runs prior to the server, we need to manually apply the body limit when reading.
bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, int64(defaultBodyLimit)))
if err != nil {
http.Error(w, "unable to parse request body", http.StatusInternalServerError)
return
}

r.Body = struct {
io.Reader
io.Closer
}{
io.MultiReader(bytes.NewReader(bodyBytes), r.Body),
r.Body,
}

txtHash := accounts.TextHash(bodyBytes)
caller, signature := common.HexToAddress(authElems[0]), common.FromHex(authElems[1])
sigPubKey, err := crypto.SigToPub(txtHash, signature)
if err != nil {
http.Error(w, "invalid authentication signature", http.StatusBadRequest)
return
}

if caller != crypto.PubkeyToAddress(*sigPubKey) {
http.Error(w, "mismatched recovered signer", http.StatusBadRequest)
return
}

// Set the authenticated caller in the context
newCtx := context.WithValue(r.Context(), authContextKey{}, &AuthContext{caller})
h.next.ServeHTTP(w, r.WithContext(newCtx))
}

func AuthFromContext(ctx context.Context) *AuthContext {
auth, ok := ctx.Value(authContextKey{}).(*AuthContext)
if !ok {
return nil
}
return auth
}
148 changes: 148 additions & 0 deletions op-txproxy/auth_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package op_txproxy

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

oprpc "github.com/ethereum-optimism/optimism/op-service/rpc"

"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/rpc"

"github.com/stretchr/testify/require"
)

var pingHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ping")
})

func TestAuthHandlerMissingAuth(t *testing.T) {
handler := authHandler{next: pingHandler}

rr := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
handler.ServeHTTP(rr, r)

// simply forwards the request
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, "ping", rr.Body.String())
}

func TestAuthHandlerBadHeader(t *testing.T) {
handler := authHandler{headerKey: "auth", next: pingHandler}

rr := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
r.Header.Set("auth", "foobarbaz")

handler.ServeHTTP(rr, r)
require.Equal(t, http.StatusBadRequest, rr.Code)
}

func TestAuthHandlerBadSignature(t *testing.T) {
handler := authHandler{headerKey: "auth", next: pingHandler}

rr := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", nil)
r.Header.Set("auth", fmt.Sprintf("%s:%s", common.HexToAddress("0xa"), "foobar"))

handler.ServeHTTP(rr, r)
require.Equal(t, http.StatusBadRequest, rr.Code)
}

func TestAuthHandlerMismatchedCaller(t *testing.T) {
handler := authHandler{headerKey: "auth", next: pingHandler}

rr := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", strings.NewReader("body"))

privKey, _ := crypto.GenerateKey()
sig, _ := crypto.Sign(accounts.TextHash([]byte("body")), privKey)
r.Header.Set("auth", fmt.Sprintf("%s:%s", common.HexToAddress("0xa"), sig))

handler.ServeHTTP(rr, r)
require.Equal(t, http.StatusBadRequest, rr.Code)
}

func TestAuthHandlerSetContext(t *testing.T) {
var ctx *AuthContext
ctxHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx = AuthFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})

handler := authHandler{headerKey: "auth", next: ctxHandler}

rr := httptest.NewRecorder()
body := bytes.NewBufferString("body")
r, _ := http.NewRequest("GET", "/", body)

privKey, _ := crypto.GenerateKey()
sig, _ := crypto.Sign(accounts.TextHash(body.Bytes()), privKey)
addr := crypto.PubkeyToAddress(privKey.PublicKey)
r.Header.Set("auth", fmt.Sprintf("%s:%s", addr, common.Bytes2Hex(sig)))

handler.ServeHTTP(rr, r)
require.Equal(t, http.StatusOK, rr.Code)

require.NotNil(t, ctx)
require.Equal(t, addr, ctx.Caller)
}

func TestAuthHandlerRpcMiddleware(t *testing.T) {
rpcServer := oprpc.NewServer("127.0.0.1", 0, "", oprpc.WithMiddleware(AuthMiddleware("auth")))
require.NoError(t, rpcServer.Start())
t.Cleanup(func() { _ = rpcServer.Stop() })

url := fmt.Sprintf("http://%s", rpcServer.Endpoint())
clnt, err := rpc.Dial(url)
require.NoError(t, err)
defer clnt.Close()

// pass without auth (default handler does not deny)
err = clnt.CallContext(context.Background(), nil, "rpc_modules")
require.Nil(t, err)

// denied with bad auth header
clnt.SetHeader("auth", "foobar")
err = clnt.CallContext(context.Background(), nil, "rpc_modules")
require.NotNil(t, err)
}

func TestAuthHandlerRequestBodyLimit(t *testing.T) {
var body []byte
bodyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
})

handler := authHandler{headerKey: "auth", next: bodyHandler}

// only up to limit is read when validating the request body
authBody := strings.Repeat("*", defaultBodyLimit)
excess := strings.Repeat("-", 10)

rr := httptest.NewRecorder()
r, _ := http.NewRequest("GET", "/", strings.NewReader(authBody+excess))

// sign over just the auth body
privKey, _ := crypto.GenerateKey()
sig, _ := crypto.Sign(accounts.TextHash([]byte(authBody)), privKey)
addr := crypto.PubkeyToAddress(privKey.PublicKey)
r.Header.Set("auth", fmt.Sprintf("%s:%s", addr, common.Bytes2Hex(sig)))

// Auth handler successfully only parses through the max body limit
handler.ServeHTTP(rr, r)
require.Equal(t, http.StatusOK, rr.Code, rr.Body)

// The next handler has the full request body present
require.Len(t, body, len(authBody)+len(excess))
}
Loading

0 comments on commit dff24e9

Please sign in to comment.