diff --git a/.github/e2e/kurtosis.yaml b/.github/e2e/kurtosis.yaml index 183cda4..95924ea 100644 --- a/.github/e2e/kurtosis.yaml +++ b/.github/e2e/kurtosis.yaml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index f876470..c1927fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/root.go b/cmd/root.go index b7bfdb0..4cd11dc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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") diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go index a23f305..0d02087 100644 --- a/pkg/builderapi/builder_preferences_test.go +++ b/pkg/builderapi/builder_preferences_test.go @@ -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) diff --git a/pkg/builderapi/epbs/auth_data.go b/pkg/builderapi/epbs/auth_data.go new file mode 100644 index 0000000..0899161 --- /dev/null +++ b/pkg/builderapi/epbs/auth_data.go @@ -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) +} diff --git a/pkg/builderapi/epbs/auth_data_test.go b/pkg/builderapi/epbs/auth_data_test.go new file mode 100644 index 0000000..66ad90d --- /dev/null +++ b/pkg/builderapi/epbs/auth_data_test.go @@ -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")) +} diff --git a/pkg/builderapi/epbs/payload_bid.go b/pkg/builderapi/epbs/payload_bid.go index 6dcc859..8d8ed10 100644 --- a/pkg/builderapi/epbs/payload_bid.go +++ b/pkg/builderapi/epbs/payload_bid.go @@ -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/...") @@ -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 } diff --git a/pkg/builderapi/epbs/preferences.go b/pkg/builderapi/epbs/preferences.go index bdd8283..84872de 100644 --- a/pkg/builderapi/epbs/preferences.go +++ b/pkg/builderapi/epbs/preferences.go @@ -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") @@ -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 } diff --git a/pkg/config/types.go b/pkg/config/types.go index 65feed5..0299a1c 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -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