Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ comment:
ignore:
- "consensus/bor/genesis_contract_mock.go"
- "consensus/bor/span_mock.go"
- "eth/peer_mock.go"

36 changes: 36 additions & 0 deletions consensus/bor/bor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,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
121 changes: 121 additions & 0 deletions consensus/bor/signbytes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package bor

import (
"bytes"
"errors"
"strings"
"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")
}
}

// TestSignBytesWithoutAuthorizedSigner covers the not-a-validator paths: a
// node that never called Authorize (or authorized the zero address) must
// refuse to sign rather than emit a signature under a zero identity.
func TestSignBytesWithoutAuthorizedSigner(t *testing.T) {
bor := &Bor{}
if _, _, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, []byte{0x01}); err == nil {
t.Fatal("SignBytes must fail with no authorized signer")
}

bor.Authorize(common.Address{}, func(accounts.Account, string, []byte) ([]byte, error) {
t.Fatal("signFn must not be reached for a zero-address signer")
return nil, nil
})
if _, _, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, []byte{0x01}); err == nil {
t.Fatal("SignBytes must fail for a zero-address signer")
}
}

// TestSignBytesPropagatesSignFnError pins that wallet/clef failures surface to
// the caller instead of returning a bogus (signer, nil-sig) pair.
func TestSignBytesPropagatesSignFnError(t *testing.T) {
bor := &Bor{}
bor.Authorize(common.HexToAddress("0x1234"), func(accounts.Account, string, []byte) ([]byte, error) {
return nil, errors.New("wallet locked")
})

_, _, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, []byte{0x01})
if err == nil || !strings.Contains(err.Error(), "wallet locked") {
t.Fatalf("expected wallet error to propagate, got %v", err)
}
}

// TestCurrentSigner covers both states of the authorized-signer lookup used by
// the wit2 announce path to decide whether this node may sign announcements.
func TestCurrentSigner(t *testing.T) {
bor := &Bor{}
if got := bor.CurrentSigner(); got != (common.Address{}) {
t.Fatalf("expected zero address before Authorize, got %s", got)
}

addr := common.HexToAddress("0x5678")
bor.Authorize(addr, func(accounts.Account, string, []byte) ([]byte, error) {
return make([]byte, 65), nil
})
if got := bor.CurrentSigner(); got != addr {
t.Fatalf("CurrentSigner: got %s want %s", got, addr)
}
}
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)
}
}
}
Loading
Loading