Skip to content
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
7 changes: 4 additions & 3 deletions .github/e2e/kurtosis.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ participants:
# From Gloas the external builder is configured on the VALIDATOR client and
# the beacon node requests bids from it on the validator's behalf, so the
# CL-side --builder.urls the package wires up only covers the pre-Gloas
# flow. The URL must match buildoor's --builder-api-url byte for byte: the
# VC signs its UTF-8 bytes as the bid request auth data, which buildoor
# verifies against its own configured builder URL. The VC flag needs
# flow. The VC signs the bid request auth data, by default the hostname of
# this URL, which buildoor verifies against its own --builder-api-url. A VC
# predating that default signs the URL's UTF-8 bytes instead, which only
# match if the URL equals --builder-api-url byte for byte. The VC flag needs
# lodestar >= v1.47.0-rc.0, hence the devnet-8 images.
vc_extra_params:
- --builder.urls=http://buildoor:8080
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,11 @@ npm run clean
preference (0 when never submitted, per spec) unless
`builder_api.ignore_preference_limit` deliberately serves beyond it (a
spec-violating bid clients should reject); gossip bids stay
`execution_payment = 0` per spec. An unset `--builder-api-url` skips the
SignedRequestAuth builder_url match on BOTH ePBS handlers (bids and
preferences alike)
`execution_payment = 0` per spec. `auth.message.data` must be the
hostname of `--builder-api-url` (the builder-specs default, see
`epbs/auth_data.go`) or, for validator clients predating that default,
the URL bytes verbatim. An unset `--builder-api-url` skips the match on
BOTH ePBS handlers (bids and preferences alike)
- Outcomes are recorded through the narrow `SlotResultRecorder` interface
(implemented by the slot results tracker): bids `served` only after a
successful response write, `suppressed`/`failed`/`cancelled` otherwise, with
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func init() {
rootCmd.PersistentFlags().Bool("builder-api-ignore-preference-limit", defaults.BuilderAPI.IgnorePreferenceLimit, "Serve the configured execution_payment portion even beyond the proposer's advertised max_execution_payment (deliberately spec-violating, for client-side rejection testing)")
rootCmd.PersistentFlags().String("builder-api-serve-candidates", defaults.BuilderAPI.ServeCandidates, "Which built candidate payloads bid requests are answered from: all, canonical_only, or a comma-separated candidate key list")
rootCmd.PersistentFlags().Bool("builder-api-on-demand-build", defaults.BuilderAPI.OnDemandBuild, "Build a payload on the fly when a bid request asks for a legal parent no candidate covers yet")
rootCmd.PersistentFlags().String("builder-api-url", defaults.BuilderAPI.BuilderURL, "Publicly reachable URL of this builder (e.g. https://builder.example.com); used to validate builder_url in SignedRequestAuthV1")
rootCmd.PersistentFlags().String("builder-api-url", defaults.BuilderAPI.BuilderURL, "Publicly reachable URL of this builder (e.g. https://builder.example.com); its hostname is used to validate auth.message.data in SignedRequestAuthV1")
rootCmd.PersistentFlags().Bool("builder-api-require-auth", defaults.BuilderAPI.RequireRequestAuth, "Require SignedRequestAuthV1 on getExecutionPayloadBid requests; reject unauthenticated requests with 401")
rootCmd.PersistentFlags().Uint64("builder-keys-target", defaults.BuilderKeys.TargetCount, "Number of builder keys to keep registered and funded (derived from the entry key; index 0 is the entry key itself)")
rootCmd.PersistentFlags().Uint64("builder-keys-max-index", defaults.BuilderKeys.MaxIndex, "Highest internal builder key index that may be derived")
Expand Down
26 changes: 26 additions & 0 deletions pkg/builderapi/builder_preferences_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,32 @@ func TestSubmitBuilderPreferences_LatestOverwrites(t *testing.T) {
assert.Equal(t, phase0.Gwei(250), got, "only the latest preference should be retained")
}

// TestSubmitBuilderPreferences_HostnameAuthData verifies that the builder-specs
// default auth data, the hostname of the builder URL, is accepted however the
// configured URL is written.
func TestSubmitBuilderPreferences_HostnameAuthData(t *testing.T) {
gfv := phase0.Version{}
blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey)
require.NoError(t, err)

cfg := &config.BuilderAPIConfig{BuilderURL: "HTTPS://Builder.Example.com:443/"}
srv := NewServer(cfg, logrus.New(), &mockChainService{}, newServingPlanService(), nil, nil, nil)
srv.SetEnabled(true)

body := signBuilderPrefsRequest(t, blsSigner, "builder.example.com", 100, 5_000_000_000, gfv)
pk := blsSigner.PublicKey()
url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:])

req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)

require.Equal(t, http.StatusAccepted, rec.Code)
_, ok := srv.GetBuilderPreferencesStore().Get(pk)
assert.True(t, ok, "preference must be stored when auth data is the builder hostname")
}

func TestSubmitBuilderPreferences_WrongBuilderURL(t *testing.T) {
gfv := phase0.Version{}
blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey)
Expand Down
72 changes: 72 additions & 0 deletions pkg/builderapi/epbs/auth_data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package epbs

import (
"bytes"
"errors"
"fmt"
"net/netip"
"net/url"
"strings"
"unicode"
)

// defaultAuthData derives the default BuilderRequestAuth data for a builder URL
// as defined by the Gloas builder-specs: the lowercased ASCII hostname, with an
// IPv6 literal inside brackets in its compressed form using hexadecimal groups
// only. Scheme, userinfo, port, path, query and fragment are not part of the
// builder's identity.
func defaultAuthData(builderURL string) ([]byte, error) {
u, err := url.Parse(builderURL)
if err != nil {
return nil, fmt.Errorf("invalid builder url: %w", err)
}

host := strings.ToLower(u.Hostname())
if host == "" {
return nil, errors.New("builder url has no hostname")
}

// Internationalized hostnames must be configured in their punycode form.
for _, r := range host {
if r > unicode.MaxASCII {
return nil, errors.New("builder url hostname is not ASCII")
}
}

if strings.Contains(host, ":") {
addr, err := netip.ParseAddr(host)
if err != nil {
return nil, fmt.Errorf("invalid IPv6 literal in builder url: %w", err)
}

host = "[" + compressIPv6(addr.WithZone("")) + "]"
}

return []byte(host), nil
}

// compressIPv6 formats an IPv6 address in its compressed form with hexadecimal
// groups only. netip renders IPv4-mapped addresses in the mixed dotted notation,
// which the builder-specs rule out.
func compressIPv6(addr netip.Addr) string {
if !addr.Is4In6() {
return addr.String()
}

b := addr.As16()

return fmt.Sprintf("::ffff:%x:%x", uint16(b[12])<<8|uint16(b[13]), uint16(b[14])<<8|uint16(b[15]))
}

// matchesAuthData reports whether data authenticates a request for the builder
// reachable at builderURL. It accepts the builder-specs default derived from the
// URL and, for validator clients that still sign them, the URL bytes verbatim.
func matchesAuthData(data []byte, builderURL string) bool {
if string(data) == builderURL {
return true
}

expected, err := defaultAuthData(builderURL)

return err == nil && bytes.Equal(data, expected)
}
56 changes: 56 additions & 0 deletions pkg/builderapi/epbs/auth_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package epbs

import (
"testing"

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

// TestDefaultAuthData covers the vectors from the builder-specs default auth
// data section.
func TestDefaultAuthData(t *testing.T) {
vectors := []struct {
url string
expected string
}{
{"https://builder.example.com/", "builder.example.com"},
{"HTTPS://Builder.Example.com:443/bids?x=1", "builder.example.com"},
{"https://builder.example.com:8080", "builder.example.com"},
{"https://user:pw@builder.example.com/", "builder.example.com"},
{"https://10.0.0.5:18550/eth/v1/builder", "10.0.0.5"},
{"https://[0:0:0:0:0:0:0:1]:8443/", "[::1]"},
{"https://[::ffff:192.0.2.1]/", "[::ffff:c000:201]"},
{"http://buildoor:8080", "buildoor"},
}

for _, v := range vectors {
data, err := defaultAuthData(v.url)
require.NoError(t, err, v.url)
assert.Equal(t, v.expected, string(data), v.url)
}
}

func TestDefaultAuthData_Invalid(t *testing.T) {
for _, builderURL := range []string{"", "builder.example.com", "https://", "https://bücher.example"} {
_, err := defaultAuthData(builderURL)
assert.Error(t, err, builderURL)
}
}

func TestMatchesAuthData(t *testing.T) {
const builderURL = "https://Builder.Example.com:8443/"

// The builder-specs default derived from the URL
assert.True(t, matchesAuthData([]byte("builder.example.com"), builderURL))
// The URL bytes verbatim, as signed by validator clients predating the default
assert.True(t, matchesAuthData([]byte(builderURL), builderURL))

assert.False(t, matchesAuthData([]byte("https://builder.example.com"), builderURL))
assert.False(t, matchesAuthData([]byte("other-builder.example.com"), builderURL))
assert.False(t, matchesAuthData(nil, builderURL))

// A URL without a hostname can only match verbatim
assert.True(t, matchesAuthData([]byte("buildoor:8080"), "buildoor:8080"))
assert.False(t, matchesAuthData([]byte("buildoor"), "buildoor:8080"))
}
10 changes: 5 additions & 5 deletions pkg/builderapi/epbs/payload_bid.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type GetExecutionPayloadBidResponse struct {
//
// If the request body contains a SignedRequestAuthV1, it is validated:
// - auth.message.slot must match the requested slot
// - auth.message.builder_url must match cfg.BuilderURL (if configured)
// - auth.message.data must match the hostname of cfg.BuilderURL (if configured)
// - BLS signature must verify against the proposer_pubkey path parameter
func (h *Handler) HandleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Request) {
log := h.log.WithField("path", "/eth/v1/builder/execution_payload_bid/...")
Expand Down Expand Up @@ -195,12 +195,12 @@ func (h *Handler) HandleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Re
writeError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.slot does not match the requested slot")
return
}
if h.cfg.BuilderURL != "" && string(signedAuth.Message.Data) != h.cfg.BuilderURL {
if h.cfg.BuilderURL != "" && !matchesAuthData(signedAuth.Message.Data, h.cfg.BuilderURL) {
log.WithFields(logrus.Fields{
"auth_url": string(signedAuth.Message.Data),
"auth_data": string(signedAuth.Message.Data),
"builder_url": h.cfg.BuilderURL,
}).Warn("getExecutionPayloadBid: SignedRequestAuth data (builder_url) mismatch")
writeError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.data does not match this builder's URL")
}).Warn("getExecutionPayloadBid: SignedRequestAuth data mismatch")
writeError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.data does not match this builder's hostname")
return
}

Expand Down
17 changes: 9 additions & 8 deletions pkg/builderapi/epbs/preferences.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import (
// It records the validator's latest max_execution_payment after authenticating
// the request via the embedded SignedRequestAuthV1. Per the Gloas builder-specs,
// the builder MUST verify the auth signature against the validator_pubkey path
// param (401 on failure) and MUST check that auth.message.builder_url matches its
// own URL (400 on failure). Like getExecutionPayloadBid, the URL check is skipped
// when no --builder-api-url is configured. The preference is stored only after
// param (401 on failure) and MUST check that auth.message.data matches the value
// it expects, by default the hostname of its own URL (400 on failure). Like
// getExecutionPayloadBid, the check is skipped when no --builder-api-url is
// configured. The preference is stored only after
// the checks pass. On success it returns 202.
func (h *Handler) HandleSubmitBuilderPreferences(w http.ResponseWriter, r *http.Request) {
log := h.log.WithField("path", "/eth/v1/builder/builder_preferences")
Expand Down Expand Up @@ -66,14 +67,14 @@ func (h *Handler) HandleSubmitBuilderPreferences(w http.ResponseWriter, r *http.
return
}

// Check auth.message.data (the builder URL) matches this builder's URL
// Check auth.message.data matches the hostname of this builder's URL
// (400 on mismatch, skipped when no URL is configured).
if h.cfg.BuilderURL != "" && string(req.Auth.Message.Data) != h.cfg.BuilderURL {
if h.cfg.BuilderURL != "" && !matchesAuthData(req.Auth.Message.Data, h.cfg.BuilderURL) {
log.WithFields(logrus.Fields{
"auth_url": string(req.Auth.Message.Data),
"auth_data": string(req.Auth.Message.Data),
"builder_url": h.cfg.BuilderURL,
}).Warn("submitBuilderPreferences: builder_url mismatch")
writeError(w, http.StatusBadRequest, "auth.message.data does not match this builder's URL")
}).Warn("submitBuilderPreferences: auth data mismatch")
writeError(w, http.StatusBadRequest, "auth.message.data does not match this builder's hostname")
return
}

Expand Down
5 changes: 3 additions & 2 deletions pkg/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,9 @@ const (
// BuilderAPIConfig defines configuration for the traditional Builder API (pre-ePBS).
type BuilderAPIConfig struct {
// BuilderURL is this builder's publicly reachable URL (e.g. "https://builder.example.com").
// Used to verify the auth.message.data field (set to the builder URL) in
// SignedRequestAuthV1 messages from proposers. If empty, this validation is skipped.
// Used to verify the auth.message.data field in SignedRequestAuthV1 messages
// from proposers, by default the hostname of this URL. If empty, this validation
// is skipped.
BuilderURL string `yaml:"builder_url" json:"builder_url"`

// RequireRequestAuth controls whether a SignedRequestAuthV1 body is mandatory on
Expand Down
Loading