Skip to content
Open
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
11 changes: 6 additions & 5 deletions accounts/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ type Account struct {
}

const (
MimetypeDataWithValidator = "data/validator"
MimetypeTypedData = "data/typed"
MimetypeClique = "application/x-clique-header"
MimetypeBor = "application/x-bor-header"
MimetypeTextPlain = "text/plain"
MimetypeDataWithValidator = "data/validator"
MimetypeTypedData = "data/typed"
MimetypeClique = "application/x-clique-header"
MimetypeBor = "application/x-bor-header"
MimetypeBorWitnessAnnounce = "application/x-bor-wit2-announce"
MimetypeTextPlain = "text/plain"
)

// Wallet represents a software or hardware wallet that might contain one or more
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