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

op-txproxy: external validating proxy for conditional transactions #42

Merged
merged 4 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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