Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 accounts/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@
}

const (
MimetypeDataWithValidator = "data/validator"

Check failure on line 40 in accounts/accounts.go

View workflow job for this annotation

GitHub Actions / lint (ubuntu-22.04)

File is not properly formatted (goimports)
MimetypeTypedData = "data/typed"
MimetypeClique = "application/x-clique-header"
MimetypeBor = "application/x-bor-header"
MimetypeBorWitnessAnnounce = "application/x-bor-wit2-announce"
MimetypeTextPlain = "text/plain"
)

Expand Down
36 changes: 36 additions & 0 deletions consensus/bor/bor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,42 @@ func Sign(signFn SignerFn, signer common.Address, header *types.Header, c *param
return nil
}

// SignBytes signs the supplied preimage bytes under a context-specific
// mimetype using the engine's currently authorized signer. The mimetype is the
// domain tag the underlying signer (clef, keystore) sees, so callers MUST pass
// a context-specific value (e.g. accounts.MimetypeBorWitnessAnnounce) and
// never reuse accounts.MimetypeBor outside of header sealing — that would let
// a signature produced here be replayed as a block-seal signature on any
// header BorRLP that hashes to the same digest.
//
// Callers pass the unhashed preimage; the wallet's SignData implementation
// applies keccak256 once before signing. Verifiers must independently hash
// the same preimage and ecrecover against the resulting digest.
func (c *Bor) SignBytes(mimetype string, digest []byte) (signer common.Address, sig []byte, err error) {
if mimetype == "" || mimetype == accounts.MimetypeBor {
return common.Address{}, nil, errors.New("bor: SignBytes requires a non-empty, non-header mimetype")
}
current := c.authorizedSigner.Load()
if current == nil || current.signer == (common.Address{}) {
return common.Address{}, nil, errors.New("bor: no authorized signer configured")
}
sig, err = current.signFn(accounts.Account{Address: current.signer}, mimetype, digest)
if err != nil {
return common.Address{}, nil, err
}
return current.signer, sig, nil
}

// CurrentSigner returns the address of the currently authorized signer, or
// the zero address if none has been configured.
func (c *Bor) CurrentSigner() common.Address {
current := c.authorizedSigner.Load()
if current == nil {
return common.Address{}
}
return current.signer
}

// CalcDifficulty is the difficulty adjustment algorithm. It returns the difficulty
// that a new block should have based on the previous blocks in the chain and the
// current signer.
Expand Down
70 changes: 70 additions & 0 deletions consensus/bor/signbytes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package bor

import (
"bytes"
"testing"

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

// TestSignBytesForwardsMimetype is the regression for the wit2 announce
// signing path's external-signer compatibility: bor.SignBytes must hand the
// caller-supplied mimetype to the configured signer untouched. Operators
// configuring Clef whitelist a specific string ("application/x-bor-wit2-
// announce"); if SignBytes ever rewrote, lower-cased, or stripped that, the
// signer would either reject the request or sign under a different domain.
//
// The test captures the (mimetype, payload) the wallet sees and asserts both
// match exactly what the caller passed.
func TestSignBytesForwardsMimetype(t *testing.T) {
bor := &Bor{}
addr := common.HexToAddress("0x1234")

var (
gotMimetype string
gotPayload []byte
)
bor.Authorize(addr, func(_ accounts.Account, mimetype string, data []byte) ([]byte, error) {
gotMimetype = mimetype
gotPayload = append([]byte(nil), data...)
return make([]byte, 65), nil
})

preimage := []byte("wit2-announce-preimage")
signer, sig, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, preimage)
if err != nil {
t.Fatalf("SignBytes: %v", err)
}
if signer != addr {
t.Fatalf("signer addr mismatch: got %s want %s", signer, addr)
}
if len(sig) != 65 {
t.Fatalf("expected 65-byte signature, got %d", len(sig))
}
if gotMimetype != accounts.MimetypeBorWitnessAnnounce {
t.Fatalf("mimetype not forwarded literally: got %q want %q",
gotMimetype, accounts.MimetypeBorWitnessAnnounce)
}
if !bytes.Equal(gotPayload, preimage) {
t.Fatalf("payload not forwarded literally: got %x want %x", gotPayload, preimage)
}
}

// TestSignBytesRejectsHeaderMimetype guards against accidental cross-context
// reuse: callers must never pass MimetypeBor (header sealing) into SignBytes,
// since that would let an announce signature replay as a block-seal.
func TestSignBytesRejectsHeaderMimetype(t *testing.T) {
bor := &Bor{}
bor.Authorize(common.HexToAddress("0x1234"), func(accounts.Account, string, []byte) ([]byte, error) {
t.Fatal("signFn must not be reached for rejected mimetype")
return nil, nil
})

if _, _, err := bor.SignBytes("", []byte{0x01}); err == nil {
t.Fatal("empty mimetype must be rejected")
}
if _, _, err := bor.SignBytes(accounts.MimetypeBor, []byte{0x01}); err == nil {
t.Fatal("MimetypeBor must be rejected to prevent header-seal replay")
}
}
26 changes: 19 additions & 7 deletions core/stateless/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
package stateless

import (
"bytes"
"io"
"sort"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
Expand Down Expand Up @@ -84,19 +86,29 @@ func (w *Witness) fromExtWitness(ext *ExtWitness) error {
// EncodeRLP serializes a witness as RLP using the canonical BorWitness 3-field
// format. Only state trie nodes are encoded; contract bytecodes are not
// included in the wire format.
//
// State entries are sorted lexicographically before encoding so the output is
// byte-identical for any two witnesses with the same logical contents. Without
// this, Go's randomized map iteration would produce different bytes per call,
// breaking any code that hashes the encoded witness for content addressing —
// notably the WIT2 BP-signed witness hash, which is computed by both producer
// and verifiers and must match exactly.
func (w *Witness) EncodeRLP(wr io.Writer) error {
w.lock.RLock()
defer w.lock.RUnlock()

bw := &BorWitness{
Context: w.context,
Headers: w.Headers,
State: make([][]byte, 0, len(w.State)),
}
state := make([][]byte, 0, len(w.State))
for node := range w.State {
bw.State = append(bw.State, []byte(node))
state = append(state, []byte(node))
}
return rlp.Encode(wr, bw)
sort.Slice(state, func(i, j int) bool {
return bytes.Compare(state[i], state[j]) < 0
})
return rlp.Encode(wr, &BorWitness{
Context: w.context,
Headers: w.Headers,
State: state,
})
}

// DecodeRLP decodes a witness from RLP. It first attempts the canonical
Expand Down
59 changes: 59 additions & 0 deletions core/stateless/encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,62 @@ func TestRoundtrip_BorWitnessFormat(t *testing.T) {
t.Errorf("Codes should be empty after BorWitness roundtrip, got %d", len(decoded.Codes))
}
}

// TestEncodeRLP_DeterministicAcrossInsertionOrder is the regression test for
// the WIT2 byte-blame model. State entries arrive via a Go map, whose
// iteration order is randomised, so without sorting in EncodeRLP two
// witnesses with identical logical content would encode to different bytes
// and hash differently. Receivers verifying response bytes against the BP-
// signed witness hash would falsely drop honest peers.
func TestEncodeRLP_DeterministicAcrossInsertionOrder(t *testing.T) {
const N = 64
nodes := make([][]byte, N)
for i := 0; i < N; i++ {
nodes[i] = []byte{byte(i), byte(i ^ 0x5a), byte(i ^ 0xa5)}
}

makeWitness := func(insertionOrder []int) *Witness {
w := &Witness{
Headers: []*types.Header{{Number: big.NewInt(1)}},
Codes: make(map[string]struct{}),
State: make(map[string]struct{}, len(insertionOrder)),
}
w.context = &types.Header{Number: big.NewInt(2)}
for _, i := range insertionOrder {
w.State[string(nodes[i])] = struct{}{}
}
return w
}

encode := func(w *Witness) []byte {
raw, err := rlp.EncodeToBytes(w)
if err != nil {
t.Fatalf("encode: %v", err)
}
return raw
}

forward := make([]int, N)
for i := range forward {
forward[i] = i
}
reverse := make([]int, N)
for i := range reverse {
reverse[i] = N - 1 - i
}

wForward := makeWitness(forward)
wReverse := makeWitness(reverse)
if got, want := encode(wForward), encode(wReverse); string(got) != string(want) {
t.Fatalf("EncodeRLP must be deterministic across map insertion orders; got divergent bytes (%d vs %d)", len(got), len(want))
}

// Re-encoding the same witness multiple times must also yield identical
// bytes, even though Go map iteration is fresh each call.
first := encode(wForward)
for i := 0; i < 5; i++ {
if string(encode(wForward)) != string(first) {
t.Fatalf("repeat encode call %d differs from first", i)
}
}
}
110 changes: 110 additions & 0 deletions core/stateless/witness_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package stateless

import (
"crypto/rand"
"fmt"
"testing"

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

// BenchmarkWitnessEncodeRLP measures the cost of EncodeRLP, which sorts
// state nodes lexicographically before serialization. Surfaces regressions if
// the comparator changes (e.g. swapping bytes.Compare for an allocating
// alternative). Synthetic 50 MiB witness with realistic node sizes.
func BenchmarkWitnessEncodeRLP(b *testing.B) {
for _, sizeMiB := range []int{1, 15, 50} {
w := buildSyntheticWitness(sizeMiB<<20, 256)
b.Run(fmt.Sprintf("%dMiB", sizeMiB), func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := w.EncodeRLP(discardWriter{}); err != nil {
b.Fatalf("encode: %v", err)
}
}
})
}
}

type discardWriter struct{}

func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }

// BenchmarkWitnessKeccakBySize measures the throughput of keccak256 over a
// pre-allocated witness-sized buffer. This is the cost the producer pays to
// compute WitnessHash on the WIT2 announce path (and the cost a relayer or
// requester pays to verify response bytes against the BP-signed WitnessHash).
//
// Run with `go test -bench=BenchmarkWitnessKeccakBySize ./core/stateless/`.
// b.SetBytes lets `go test -benchmem` print throughput in MB/s alongside ns/op,
// which is what we actually want to know — the absolute size of any one
// witness varies, but per-byte cost scales linearly.
func BenchmarkWitnessKeccakBySize(b *testing.B) {
for _, sizeMiB := range []int{1, 5, 15, 30, 50} {
size := sizeMiB << 20
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
b.Fatalf("rand: %v", err)
}
b.Run(fmt.Sprintf("%dMiB", sizeMiB), func(b *testing.B) {
b.SetBytes(int64(size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = crypto.Keccak256Hash(buf)
}
})
}
}

// BenchmarkWitnessAnnounceSign measures the marginal ECDSA cost of signing the
// 32-byte announcement digest, independent of witness size. This isolates the
// secp256k1 sign cost from the keccak cost so a single number per platform is
// directly comparable to libsecp256k1 microbenchmarks.
func BenchmarkWitnessAnnounceSign(b *testing.B) {
key, err := crypto.GenerateKey()
if err != nil {
b.Fatalf("key: %v", err)
}
digest := make([]byte, 32)
if _, err := rand.Read(digest); err != nil {
b.Fatalf("rand: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := crypto.Sign(digest, key); err != nil {
b.Fatalf("sign: %v", err)
}
}
}

// BenchmarkWitnessHashAndSignCombined measures the realistic producer-side
// cost of the WIT2 announce path: keccak256 over witness bytes followed by
// ECDSA sign over the (small) signing digest. This is the latency the BP
// adds before emitting a signed announce. Compare against the ~500ms-per-hop
// savings: as long as this stays well under the savings, the change is a
// net win even at 50 MiB witnesses.
func BenchmarkWitnessHashAndSignCombined(b *testing.B) {
key, err := crypto.GenerateKey()
if err != nil {
b.Fatalf("key: %v", err)
}
for _, sizeMiB := range []int{1, 5, 15, 30, 50} {
size := sizeMiB << 20
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
b.Fatalf("rand: %v", err)
}
b.Run(fmt.Sprintf("%dMiB", sizeMiB), func(b *testing.B) {
b.SetBytes(int64(size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
witnessHash := crypto.Keccak256Hash(buf)
digest := crypto.Keccak256Hash(witnessHash[:], []byte{0x01, 0x02, 0x03, 0x04})
if _, err := crypto.Sign(digest[:], key); err != nil {
b.Fatalf("sign: %v", err)
}
}
})
}
}
Loading
Loading