Skip to content

Commit 3d349ed

Browse files
committed
fix: classify invalid external blocks safely
1 parent c811388 commit 3d349ed

5 files changed

Lines changed: 219 additions & 7 deletions

File tree

block/internal/syncing/syncer.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,12 +897,28 @@ func (s *Syncer) ValidateBlock(_ context.Context, currState types.State, data *t
897897
return fmt.Errorf("invalid header: %w", err)
898898
}
899899

900+
if err := currState.AssertExpectedProposer(header); err != nil {
901+
return errors.Join(errInvalidBlock, err)
902+
}
903+
900904
if err := currState.AssertValidForNextState(header, data); err != nil {
905+
if isExternalBlockValidationError(err) {
906+
return errors.Join(errInvalidBlock, err)
907+
}
901908
return errors.Join(errInvalidState, err)
902909
}
903910
return nil
904911
}
905912

913+
func isExternalBlockValidationError(err error) bool {
914+
return errors.Is(err, types.ErrUnexpectedProposer) ||
915+
errors.Is(err, types.ErrInvalidChainID) ||
916+
errors.Is(err, types.ErrInvalidBlockHeight) ||
917+
errors.Is(err, types.ErrInvalidBlockTime) ||
918+
errors.Is(err, types.ErrHeaderDataMismatch) ||
919+
errors.Is(err, types.ErrDataHashMismatch)
920+
}
921+
906922
var errMaliciousProposer = errors.New("malicious proposer detected")
907923

908924
// hashTx returns a hex-encoded SHA256 hash of the transaction.

block/internal/syncing/syncer_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,144 @@ func TestSyncer_ValidateBlock_UsesStateNextProposer(t *testing.T) {
214214
require.Contains(t, err.Error(), "unexpected proposer")
215215
}
216216

217+
func TestSyncer_TrySyncNextBlock_ClassifiesExternalValidationFailures(t *testing.T) {
218+
expectedAddr, expectedPub, expectedSigner := buildSyncTestSigner(t)
219+
wrongAddr, wrongPub, wrongSigner := buildSyncTestSigner(t)
220+
221+
now := time.Now()
222+
baseState := types.State{
223+
ChainID: "tchain",
224+
InitialHeight: 1,
225+
LastBlockHeight: 1,
226+
LastBlockTime: now,
227+
LastHeaderHash: []byte("last-header-hash"),
228+
AppHash: []byte("app0"),
229+
NextProposerAddress: expectedAddr,
230+
}
231+
232+
makeSyncer := func(tb testing.TB) *Syncer {
233+
tb.Helper()
234+
ds := dssync.MutexWrap(datastore.NewMapDatastore())
235+
st := store.New(ds)
236+
cm, err := cache.NewManager(config.DefaultConfig(), st, zerolog.Nop())
237+
require.NoError(tb, err)
238+
239+
return &Syncer{
240+
cache: cm,
241+
logger: zerolog.Nop(),
242+
options: common.DefaultBlockOptions(),
243+
}
244+
}
245+
246+
makeEvent := func(tb testing.TB, chainID string, proposer []byte, pub crypto.PubKey, signer signerpkg.Signer, appHash []byte, data *types.Data) common.DAHeightEvent {
247+
tb.Helper()
248+
_, header := makeSignedHeaderBytes(tb, chainID, 2, proposer, pub, signer, appHash, data, baseState.LastHeaderHash)
249+
return common.DAHeightEvent{
250+
Header: header,
251+
Data: data,
252+
Source: common.SourceDA,
253+
}
254+
}
255+
256+
tests := map[string]struct {
257+
event func(testing.TB) common.DAHeightEvent
258+
wantState bool
259+
wantInvalid bool
260+
}{
261+
"wrong proposer with bad app hash is an invalid external block": {
262+
event: func(tb testing.TB) common.DAHeightEvent {
263+
data := makeData(baseState.ChainID, 2, 1)
264+
data.Metadata.Time = uint64(now.Add(time.Second).UnixNano())
265+
return makeEvent(tb, baseState.ChainID, wrongAddr, wrongPub, wrongSigner, []byte("forged-app"), data)
266+
},
267+
wantInvalid: true,
268+
},
269+
"wrong chain ID is an invalid external block": {
270+
event: func(tb testing.TB) common.DAHeightEvent {
271+
data := makeData("other-chain", 2, 1)
272+
data.Metadata.Time = uint64(now.Add(time.Second).UnixNano())
273+
return makeEvent(tb, "other-chain", expectedAddr, expectedPub, expectedSigner, baseState.AppHash, data)
274+
},
275+
wantInvalid: true,
276+
},
277+
"data hash mismatch is an invalid external block": {
278+
event: func(tb testing.TB) common.DAHeightEvent {
279+
headerData := makeData(baseState.ChainID, 2, 1)
280+
headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano())
281+
event := makeEvent(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData)
282+
event.Data = makeData(baseState.ChainID, 2, 2)
283+
event.Data.Metadata.Time = headerData.Metadata.Time
284+
return event
285+
},
286+
wantInvalid: true,
287+
},
288+
"header data metadata mismatch is an invalid external block": {
289+
event: func(tb testing.TB) common.DAHeightEvent {
290+
headerData := makeData(baseState.ChainID, 2, 1)
291+
headerData.Metadata.Time = uint64(now.Add(time.Second).UnixNano())
292+
event := makeEvent(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, baseState.AppHash, headerData)
293+
event.Data = &types.Data{
294+
Metadata: &types.Metadata{
295+
ChainID: baseState.ChainID,
296+
Height: 3,
297+
Time: headerData.Metadata.Time,
298+
},
299+
Txs: headerData.Txs,
300+
}
301+
return event
302+
},
303+
wantInvalid: true,
304+
},
305+
"expected proposer with bad app hash is invalid state": {
306+
event: func(tb testing.TB) common.DAHeightEvent {
307+
data := makeData(baseState.ChainID, 2, 1)
308+
data.Metadata.Time = uint64(now.Add(time.Second).UnixNano())
309+
return makeEvent(tb, baseState.ChainID, expectedAddr, expectedPub, expectedSigner, []byte("wrong-app"), data)
310+
},
311+
wantState: true,
312+
},
313+
}
314+
315+
for name, tc := range tests {
316+
t.Run(name, func(t *testing.T) {
317+
event := tc.event(t)
318+
err := makeSyncer(t).trySyncNextBlockWithState(t.Context(), &event, baseState)
319+
require.Error(t, err)
320+
assert.Equal(t, tc.wantInvalid, errors.Is(err, errInvalidBlock), "invalid block classification")
321+
assert.Equal(t, tc.wantState, errors.Is(err, errInvalidState), "invalid state classification")
322+
})
323+
}
324+
}
325+
326+
func TestSyncer_ValidateBlock_RejectsSignerAddressNotDerivedFromPubKey(t *testing.T) {
327+
expectedAddr, _, _ := buildSyncTestSigner(t)
328+
_, attackerPub, attackerSigner := buildSyncTestSigner(t)
329+
330+
now := time.Now()
331+
state := types.State{
332+
ChainID: "tchain",
333+
InitialHeight: 1,
334+
LastBlockHeight: 1,
335+
LastBlockTime: now,
336+
LastHeaderHash: []byte("last-header-hash"),
337+
AppHash: []byte("app0"),
338+
NextProposerAddress: expectedAddr,
339+
}
340+
341+
data := makeData(state.ChainID, 2, 1)
342+
data.Metadata.Time = uint64(now.Add(time.Second).UnixNano())
343+
_, header := makeSignedHeaderBytes(t, state.ChainID, 2, expectedAddr, attackerPub, attackerSigner, state.AppHash, data, state.LastHeaderHash)
344+
345+
s := &Syncer{
346+
logger: zerolog.Nop(),
347+
options: common.DefaultBlockOptions(),
348+
}
349+
350+
err := s.ValidateBlock(t.Context(), state, data, header)
351+
require.Error(t, err)
352+
require.Contains(t, err.Error(), "signer address")
353+
}
354+
217355
func TestSyncer_ApplyBlockPersistsExecutionNextProposer(t *testing.T) {
218356
addr, _, _ := buildSyncTestSigner(t)
219357
execNext := []byte("execution-next-proposer")

types/data.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import (
99
"google.golang.org/protobuf/proto"
1010
)
1111

12+
var (
13+
// ErrHeaderDataMismatch is returned when header and data metadata do not describe the same block.
14+
ErrHeaderDataMismatch = errors.New("header and data do not match")
15+
16+
// ErrDataHashMismatch is returned when a header's data hash does not match its block data.
17+
ErrDataHashMismatch = errors.New("dataHash from the header does not match with hash of the block's data")
18+
)
19+
1220
// Version captures the consensus rules for processing a block in the blockchain,
1321
// including all blockchain data structures and the rules of the application's
1422
// state transition machine.
@@ -60,14 +68,14 @@ func Validate(header *SignedHeader, data *Data) error {
6068
if header.ChainID() != data.ChainID() ||
6169
header.Height() != data.Height() ||
6270
header.Time() != data.Time() { // skipping LastDataHash comparison as it needs access to previous header
63-
return errors.New("header and data do not match")
71+
return ErrHeaderDataMismatch
6472
}
6573
}
6674
// exclude Metadata while computing the data hash for comparison
6775
d := Data{Txs: data.Txs}
6876
dataHash := d.DACommitment()
6977
if !bytes.Equal(dataHash[:], header.DataHash[:]) {
70-
return errors.New("dataHash from the header does not match with hash of the block's data")
78+
return ErrDataHashMismatch
7179
}
7280
return nil
7381
}

types/signed_header.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ var (
102102
// ErrProposerAddressMismatch is returned when the proposer address in the signed header does not match the proposer address in the validator set
103103
ErrProposerAddressMismatch = errors.New("proposer address in SignedHeader does not match the proposer address in the validator set")
104104

105+
// ErrSignerPubKeyMissing is returned when the signed header does not include the signer public key.
106+
ErrSignerPubKeyMissing = errors.New("signer public key is missing")
107+
108+
// ErrSignerAddressMismatch is returned when the signer address does not derive from the signer public key.
109+
ErrSignerAddressMismatch = errors.New("signer address does not match signer public key")
110+
105111
// ErrSignatureEmpty is returned when signature is empty
106112
ErrSignatureEmpty = errors.New("signature is empty")
107113
)
@@ -116,6 +122,10 @@ func (sh *SignedHeader) ValidateBasic() error {
116122
return err
117123
}
118124

125+
if err := sh.validateSignerIdentity(); err != nil {
126+
return err
127+
}
128+
119129
// Check that the proposer address in the signed header matches the proposer address in the validator set
120130
if !bytes.Equal(sh.ProposerAddress, sh.Signer.Address) {
121131
return ErrProposerAddressMismatch
@@ -195,6 +205,10 @@ func (sh *SignedHeader) ValidateBasicWithData(data *Data) error {
195205
return err
196206
}
197207

208+
if err := sh.validateSignerIdentity(); err != nil {
209+
return err
210+
}
211+
198212
// Check that the proposer address in the signed header matches the proposer address in the validator set
199213
if !bytes.Equal(sh.ProposerAddress, sh.Signer.Address) {
200214
return ErrProposerAddressMismatch
@@ -263,3 +277,13 @@ func (sh *SignedHeader) ValidateBasicWithData(data *Data) error {
263277

264278
return ErrSignatureVerificationFailed
265279
}
280+
281+
func (sh *SignedHeader) validateSignerIdentity() error {
282+
if sh.Signer.PubKey == nil {
283+
return ErrSignerPubKeyMissing
284+
}
285+
if !bytes.Equal(KeyAddress(sh.Signer.PubKey), sh.Signer.Address) {
286+
return ErrSignerAddressMismatch
287+
}
288+
return nil
289+
}

types/state.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@ var InitStateVersion = Version{
2121
// different from the proposer expected by the current state.
2222
var ErrUnexpectedProposer = errors.New("unexpected proposer")
2323

24+
var (
25+
// ErrInvalidChainID is returned when a header belongs to a different chain than the current state.
26+
ErrInvalidChainID = errors.New("invalid chain ID")
27+
28+
// ErrInvalidBlockHeight is returned when a header is not the next height after the current state.
29+
ErrInvalidBlockHeight = errors.New("invalid block height")
30+
31+
// ErrInvalidBlockTime is returned when a header time is earlier than the current state's block time.
32+
ErrInvalidBlockTime = errors.New("invalid block time")
33+
34+
// ErrInvalidLastHeaderHash is returned when a header does not link to the current state's last header.
35+
ErrInvalidLastHeaderHash = errors.New("invalid last header hash")
36+
37+
// ErrInvalidLastAppHash is returned when a header does not reference the current state's app hash.
38+
ErrInvalidLastAppHash = errors.New("invalid last app hash")
39+
)
40+
2441
// State contains information about current state of the blockchain.
2542
type State struct {
2643
Version Version
@@ -74,13 +91,22 @@ func (s *State) NextState(header Header, stateRoot []byte, nextProposerAddress .
7491
// AssertValidForNextState performs common validation of a header and data against the current state.
7592
// It assumes any context-specific basic header checks and verifier setup have already been performed
7693
func (s State) AssertValidForNextState(header *SignedHeader, data *Data) error {
94+
if err := s.AssertExpectedProposer(header); err != nil {
95+
return err
96+
}
97+
7798
if err := s.AssertValidSequence(header); err != nil {
7899
return err
79100
}
80101

81102
if err := Validate(header, data); err != nil {
82103
return fmt.Errorf("header-data validation failed: %w", err)
83104
}
105+
return nil
106+
}
107+
108+
// AssertExpectedProposer checks that the header was signed by the proposer expected for the next block.
109+
func (s State) AssertExpectedProposer(header *SignedHeader) error {
84110
if len(s.NextProposerAddress) > 0 && !bytes.Equal(header.ProposerAddress, s.NextProposerAddress) {
85111
return fmt.Errorf("%w - got: %x, want: %x", ErrUnexpectedProposer, header.ProposerAddress, s.NextProposerAddress)
86112
}
@@ -95,27 +121,27 @@ var (
95121
// AssertValidSequence performs lightweight state-sequence validation for self-produced blocks.
96122
func (s State) AssertValidSequence(header *SignedHeader) error {
97123
if header.ChainID() != s.ChainID {
98-
return fmt.Errorf("invalid chain ID - got %s, want %s", header.ChainID(), s.ChainID)
124+
return fmt.Errorf("%w - got %s, want %s", ErrInvalidChainID, header.ChainID(), s.ChainID)
99125
}
100126

101127
if len(s.LastHeaderHash) == 0 { // initial state
102128
return nil
103129
}
104130

105131
if expdHeight := s.LastBlockHeight + 1; header.Height() != expdHeight {
106-
return fmt.Errorf("invalid block height - got: %d, want: %d", header.Height(), expdHeight)
132+
return fmt.Errorf("%w - got: %d, want: %d", ErrInvalidBlockHeight, header.Height(), expdHeight)
107133
}
108134

109135
if headerTime := header.Time(); s.LastBlockTime.After(headerTime) {
110-
return fmt.Errorf("invalid block time - got: %v, last: %v", headerTime, s.LastBlockTime)
136+
return fmt.Errorf("%w - got: %v, last: %v", ErrInvalidBlockTime, headerTime, s.LastBlockTime)
111137
}
112138

113139
// Trick to support the switch from a base sequencer to a normal syncing node.
114140
// Based sequencers do not sign ehaders, meaning the last header hash will be different from the
115141
// newly derived header hash when a base sequencer have switched to a syncing node
116142
if !bytes.Equal(header.LastHeaderHash, s.LastHeaderHash) {
117143
if lastHeaderHashErrCount == 1 {
118-
return fmt.Errorf("invalid last header hash - got: %x, want: %x", header.LastHeaderHash, s.LastHeaderHash)
144+
return fmt.Errorf("%w - got: %x, want: %x", ErrInvalidLastHeaderHash, header.LastHeaderHash, s.LastHeaderHash)
119145
}
120146

121147
basedSequencerTracking.Do(func() {
@@ -124,7 +150,7 @@ func (s State) AssertValidSequence(header *SignedHeader) error {
124150
}
125151

126152
if !bytes.Equal(header.AppHash, s.AppHash) {
127-
return fmt.Errorf("invalid last app hash - got: %x, want: %x", header.AppHash, s.AppHash)
153+
return fmt.Errorf("%w - got: %x, want: %x", ErrInvalidLastAppHash, header.AppHash, s.AppHash)
128154
}
129155

130156
return nil

0 commit comments

Comments
 (0)