Skip to content

Commit 742dcba

Browse files
committed
Introduce envelope for headers on DA to fail fast on unauthorized content
1 parent d386df5 commit 742dcba

File tree

11 files changed

+390
-52
lines changed

11 files changed

+390
-52
lines changed

block/internal/submitting/da_submitter.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"time"
99

1010
"github.com/rs/zerolog"
11-
"google.golang.org/protobuf/proto"
1211

1312
"github.com/evstack/ev-node/block/internal/cache"
1413
"github.com/evstack/ev-node/block/internal/common"
@@ -161,7 +160,7 @@ func (s *DASubmitter) recordFailure(reason common.DASubmitterFailureReason) {
161160
}
162161

163162
// SubmitHeaders submits pending headers to DA layer
164-
func (s *DASubmitter) SubmitHeaders(ctx context.Context, cache cache.Manager) error {
163+
func (s *DASubmitter) SubmitHeaders(ctx context.Context, cache cache.Manager, signer signer.Signer) error {
165164
headers, err := cache.GetPendingHeaders(ctx)
166165
if err != nil {
167166
return fmt.Errorf("failed to get pending headers: %w", err)
@@ -171,15 +170,29 @@ func (s *DASubmitter) SubmitHeaders(ctx context.Context, cache cache.Manager) er
171170
return nil
172171
}
173172

173+
if signer == nil {
174+
return fmt.Errorf("signer is nil")
175+
}
176+
174177
s.logger.Info().Int("count", len(headers)).Msg("submitting headers to DA")
175178

176179
return submitToDA(s, ctx, headers,
177180
func(header *types.SignedHeader) ([]byte, error) {
178-
headerPb, err := header.ToProto()
181+
// A. Marshal the inner SignedHeader content to bytes (canonical representation for signing)
182+
// This effectively signs "Fields 1-3" of the intended DAHeaderEnvelope.
183+
contentBytes, err := header.MarshalBinary()
179184
if err != nil {
180-
return nil, fmt.Errorf("failed to convert header to proto: %w", err)
185+
return nil, fmt.Errorf("failed to marshal signed header for envelope signing: %w", err)
181186
}
182-
return proto.Marshal(headerPb)
187+
188+
// B. Sign the contentBytes with the envelope signer (aggregator)
189+
envelopeSignature, err := signer.Sign(contentBytes)
190+
if err != nil {
191+
return nil, fmt.Errorf("failed to sign envelope: %w", err)
192+
}
193+
194+
// C. Create the envelope and marshal it
195+
return header.MarshalDAEnvelope(envelopeSignature)
183196
},
184197
func(submitted []*types.SignedHeader, res *datypes.ResultSubmit) {
185198
for _, header := range submitted {

block/internal/submitting/da_submitter_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ func TestDASubmitter_SubmitHeadersAndData_MarksInclusionAndUpdatesLastSubmitted(
9999
daSubmitter := NewDASubmitter(client, cfg, gen, common.DefaultBlockOptions(), common.NopMetrics(), zerolog.Nop())
100100

101101
// Submit headers and data
102-
require.NoError(t, daSubmitter.SubmitHeaders(context.Background(), cm))
102+
require.NoError(t, daSubmitter.SubmitHeaders(context.Background(), cm, n))
103103
require.NoError(t, daSubmitter.SubmitData(context.Background(), cm, n, gen))
104104

105105
// After submission, inclusion markers should be set

block/internal/submitting/da_submitter_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func TestDASubmitter_SubmitHeaders_Success(t *testing.T) {
213213
require.NoError(t, batch2.Commit())
214214

215215
// Submit headers
216-
err = submitter.SubmitHeaders(ctx, cm)
216+
err = submitter.SubmitHeaders(ctx, cm, signer)
217217
require.NoError(t, err)
218218

219219
// Verify headers are marked as DA included
@@ -229,8 +229,11 @@ func TestDASubmitter_SubmitHeaders_NoPendingHeaders(t *testing.T) {
229229
submitter, _, cm, mockDA, _ := setupDASubmitterTest(t)
230230
ctx := context.Background()
231231

232+
// Create test signer
233+
_, _, signer := createTestSigner(t)
234+
232235
// Submit headers when none are pending
233-
err := submitter.SubmitHeaders(ctx, cm)
236+
err := submitter.SubmitHeaders(ctx, cm, signer)
234237
require.NoError(t, err) // Should succeed with no action
235238
mockDA.AssertNotCalled(t, "Submit", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
236239
}

block/internal/submitting/submitter.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525

2626
// daSubmitterAPI defines minimal methods needed by Submitter for DA submissions.
2727
type daSubmitterAPI interface {
28-
SubmitHeaders(ctx context.Context, cache cache.Manager) error
28+
SubmitHeaders(ctx context.Context, cache cache.Manager, signer signer.Signer) error
2929
SubmitData(ctx context.Context, cache cache.Manager, signer signer.Signer, genesis genesis.Genesis) error
3030
}
3131

@@ -158,7 +158,7 @@ func (s *Submitter) daSubmissionLoop() {
158158
s.logger.Debug().Time("t", time.Now()).Uint64("headers", headersNb).Msg("Header submission completed")
159159
s.headerSubmissionMtx.Unlock()
160160
}()
161-
if err := s.daSubmitter.SubmitHeaders(s.ctx, s.cache); err != nil {
161+
if err := s.daSubmitter.SubmitHeaders(s.ctx, s.cache, s.signer); err != nil {
162162
// Check for unrecoverable errors that indicate a critical issue
163163
if errors.Is(err, common.ErrOversizedItem) {
164164
s.logger.Error().Err(err).

block/internal/submitting/submitter_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ type fakeDASubmitter struct {
399399
chData chan struct{}
400400
}
401401

402-
func (f *fakeDASubmitter) SubmitHeaders(ctx context.Context, _ cache.Manager) error {
402+
func (f *fakeDASubmitter) SubmitHeaders(ctx context.Context, _ cache.Manager, _ signer.Signer) error {
403403
select {
404404
case f.chHdr <- struct{}{}:
405405
default:

block/internal/syncing/da_retriever.go

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type daRetriever struct {
3434
// on restart, will be refetch as da height is updated by syncer
3535
pendingHeaders map[uint64]*types.SignedHeader
3636
pendingData map[uint64]*types.Data
37+
38+
// strictMode indicates if the node has seen a valid DAHeaderEnvelope
39+
// and should now reject all legacy/unsigned headers.
40+
strictMode bool
3741
}
3842

3943
// NewDARetriever creates a new DA retriever
@@ -50,6 +54,7 @@ func NewDARetriever(
5054
logger: logger.With().Str("component", "da_retriever").Logger(),
5155
pendingHeaders: make(map[uint64]*types.SignedHeader),
5256
pendingData: make(map[uint64]*types.Data),
57+
strictMode: false,
5358
}
5459
}
5560

@@ -228,15 +233,64 @@ func (r *daRetriever) processBlobs(ctx context.Context, blobs [][]byte, daHeight
228233
// tryDecodeHeader attempts to decode a blob as a header
229234
func (r *daRetriever) tryDecodeHeader(bz []byte, daHeight uint64) *types.SignedHeader {
230235
header := new(types.SignedHeader)
231-
var headerPb pb.SignedHeader
232236

233-
if err := proto.Unmarshal(bz, &headerPb); err != nil {
234-
return nil
235-
}
237+
isValidEnvelope := false
238+
239+
// Attempt to unmarshal as DAHeaderEnvelope and get the envelope signature
240+
if envelopeSignature, err := header.UnmarshalDAEnvelope(bz); err != nil {
241+
// If in strict mode, we REQUIRE an envelope.
242+
if r.strictMode {
243+
r.logger.Warn().Err(err).Msg("strict mode is enabled, rejecting non-envelope blob")
244+
return nil
245+
}
236246

237-
if err := header.FromProto(&headerPb); err != nil {
247+
// Fallback for backward compatibility (only if NOT in strict mode)
248+
r.logger.Debug().Msg("trying legacy decoding")
249+
var headerPb pb.SignedHeader
250+
if errLegacy := proto.Unmarshal(bz, &headerPb); errLegacy != nil {
251+
return nil
252+
}
253+
if errLegacy := header.FromProto(&headerPb); errLegacy != nil {
254+
return nil
255+
}
256+
} else {
257+
// We have a structurally valid envelope (or at least it parsed)
258+
if len(envelopeSignature) > 0 {
259+
if header.Signer.PubKey == nil {
260+
r.logger.Debug().Msg("header signer has no pubkey, cannot verify envelope")
261+
return nil
262+
}
263+
payload, err := header.MarshalBinary()
264+
if err != nil {
265+
r.logger.Debug().Err(err).Msg("failed to marshal header for verification")
266+
return nil
267+
}
268+
if valid, err := header.Signer.PubKey.Verify(payload, envelopeSignature); err != nil || !valid {
269+
r.logger.Info().Err(err).Msg("DA envelope signature verification failed")
270+
return nil
271+
}
272+
r.logger.Debug().Uint64("height", header.Height()).Msg("DA envelope signature verified")
273+
isValidEnvelope = true
274+
} else {
275+
// No signature in envelope? Treat as legacy or invalid.
276+
if r.strictMode {
277+
r.logger.Warn().Msg("strict mode is enabled, rejecting envelope without signature")
278+
return nil
279+
}
280+
}
281+
}
282+
if r.strictMode && !isValidEnvelope {
283+
r.logger.Warn().Msg("strict mode: rejecting block that is not a fully valid envelope")
238284
return nil
239285
}
286+
// Mode Switch Logic
287+
if isValidEnvelope && !r.strictMode {
288+
r.logger.Info().Uint64("height", header.Height()).Msg("valid DA envelope detected, switching to STRICT MODE")
289+
r.strictMode = true
290+
}
291+
292+
// Legacy blob support implies: strictMode == false AND (!isValidEnvelope).
293+
// We fall through here.
240294

241295
// Basic validation
242296
if err := header.Header.ValidateBasic(); err != nil {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package syncing
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/evstack/ev-node/pkg/config"
11+
"github.com/evstack/ev-node/pkg/genesis"
12+
"github.com/evstack/ev-node/types"
13+
)
14+
15+
func TestDARetriever_StrictEnvelopeMode_Switch(t *testing.T) {
16+
// Setup keys
17+
addr, pub, signer := buildSyncTestSigner(t)
18+
gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr}
19+
20+
r := newTestDARetriever(t, nil, config.DefaultConfig(), gen)
21+
22+
// 1. Create a Legacy Header (SignedHeader marshaled directly)
23+
// This simulates old blobs on the network before the upgrade.
24+
legacyHeader := &types.SignedHeader{
25+
Header: types.Header{
26+
BaseHeader: types.BaseHeader{ChainID: gen.ChainID, Height: 1, Time: uint64(time.Now().UnixNano())},
27+
ProposerAddress: addr,
28+
},
29+
Signer: types.Signer{PubKey: pub, Address: addr},
30+
}
31+
// Sign it
32+
bz, err := types.DefaultAggregatorNodeSignatureBytesProvider(&legacyHeader.Header)
33+
require.NoError(t, err)
34+
sig, err := signer.Sign(bz)
35+
require.NoError(t, err)
36+
legacyHeader.Signature = sig
37+
38+
legacyBlob, err := legacyHeader.MarshalBinary()
39+
require.NoError(t, err)
40+
41+
// 2. Create an Envelope Header (DAHeaderEnvelope)
42+
// This simulates a new blob after upgrade.
43+
envelopeHeader := &types.SignedHeader{
44+
Header: types.Header{
45+
BaseHeader: types.BaseHeader{ChainID: gen.ChainID, Height: 2, Time: uint64(time.Now().UnixNano())},
46+
ProposerAddress: addr,
47+
},
48+
Signer: types.Signer{PubKey: pub, Address: addr},
49+
}
50+
// Sign content
51+
bz2, err := types.DefaultAggregatorNodeSignatureBytesProvider(&envelopeHeader.Header)
52+
require.NoError(t, err)
53+
sig2, err := signer.Sign(bz2)
54+
require.NoError(t, err)
55+
envelopeHeader.Signature = sig2
56+
57+
// Create Envelope
58+
// We need to sign the envelope itself.
59+
// The `SubmitHeaders` logic wraps it. We emulate it here using `MarshalDAEnvelope`.
60+
// First get canonical content bytes (fields 1-3)
61+
contentBytes, err := envelopeHeader.MarshalBinary()
62+
require.NoError(t, err)
63+
// Sign envelope
64+
envSig, err := signer.Sign(contentBytes)
65+
require.NoError(t, err)
66+
// Marshal to envelope
67+
envelopeBlob, err := envelopeHeader.MarshalDAEnvelope(envSig)
68+
require.NoError(t, err)
69+
70+
// --- Test Scenario ---
71+
72+
// A. Initial State: StrictMode is false. Legacy blob should be accepted.
73+
assert.False(t, r.strictMode)
74+
75+
decodedLegacy := r.tryDecodeHeader(legacyBlob, 100)
76+
require.NotNil(t, decodedLegacy)
77+
assert.Equal(t, uint64(1), decodedLegacy.Height())
78+
79+
// StrictMode should still be false because it was a legacy blob
80+
assert.False(t, r.strictMode)
81+
82+
// B. Receiving Envelope: Should be accepted and Switch StrictMode to true.
83+
decodedEnvelope := r.tryDecodeHeader(envelopeBlob, 101)
84+
require.NotNil(t, decodedEnvelope)
85+
assert.Equal(t, uint64(2), decodedEnvelope.Height())
86+
87+
assert.True(t, r.strictMode, "retriever should have switched to strict mode")
88+
89+
// C. Receiving Legacy again: Should be REJECTED now.
90+
// We reuse the same legacyBlob (or a new one, doesn't matter, structure is legacy).
91+
decodedLegacyAgain := r.tryDecodeHeader(legacyBlob, 102)
92+
assert.Nil(t, decodedLegacyAgain, "legacy blob should be rejected in strict mode")
93+
94+
// D. Receiving Envelope again: Should still be accepted.
95+
decodedEnvelopeAgain := r.tryDecodeHeader(envelopeBlob, 103)
96+
require.NotNil(t, decodedEnvelopeAgain)
97+
}

execution/evm/types/pb/execution/evm/v1/state.pb.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/evnode/v1/evnode.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ message SignedHeader {
4747
Header header = 1;
4848
bytes signature = 2;
4949
Signer signer = 3;
50+
// Reserved for DAHeaderEnvelope envelope_signature
51+
reserved 4;
52+
}
53+
54+
// DAHeaderEnvelope is a wrapper around SignedHeader for DA submission.
55+
// It is binary compatible with SignedHeader (fields 1-3) but adds an envelope signature.
56+
message DAHeaderEnvelope {
57+
Header header = 1;
58+
bytes signature = 2;
59+
Signer signer = 3;
60+
bytes envelope_signature = 4;
5061
}
5162

5263
// Signer is a signer of a block in the blockchain.

0 commit comments

Comments
 (0)