Skip to content

Commit bfb2155

Browse files
chore: prevent EOTS private key extraction via duplicate height validation (backport #738) (#745)
<hr>This is an automatic backport of pull request #738 done by [Mergify](https://mergify.com). Co-authored-by: Lazar <[email protected]>
1 parent 924cb1f commit bfb2155

File tree

7 files changed

+207
-6
lines changed

7 files changed

+207
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
4949
* [#736](https://github.com/babylonlabs-io/finality-provider/pull/736) chore: disable flag on unsafe/test RPCs
5050
* [#737](https://github.com/babylonlabs-io/finality-provider/pull/737) chore: add fp identifier to logs
5151
* [#739](https://github.com/babylonlabs-io/finality-provider/pull/739) chore: bump babylon to `v4.0.0`
52+
* [#738](https://github.com/babylonlabs-io/finality-provider/pull/738) chore: prevent EOTS private key extraction via duplicate height validation
5253

5354
## v2.0.0-rc.5
5455

clientcontroller/babylon/consumer.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/babylonlabs-io/finality-provider/clientcontroller/api"
2121
fpcfg "github.com/babylonlabs-io/finality-provider/finality-provider/config"
2222
"github.com/babylonlabs-io/finality-provider/types"
23+
"github.com/babylonlabs-io/finality-provider/util"
2324
)
2425

2526
var _ api.ConsumerController = &BabylonConsumerController{}
@@ -240,10 +241,18 @@ func (bc *BabylonConsumerController) queryLatestBlocks(startKey []byte, count ui
240241
return nil, fmt.Errorf("failed to query finalized blocks: %w", err)
241242
}
242243

244+
// Validate no duplicate heights from RPC response (defense-in-depth)
245+
// Malicious/buggy RPC could return duplicate heights causing EOTS key extraction
246+
heights := make([]uint64, 0, len(res.Blocks))
243247
for _, b := range res.Blocks {
248+
heights = append(heights, b.Height)
244249
blocks = append(blocks, types.NewBlockInfo(b.Height, b.AppHash, b.Finalized))
245250
}
246251

252+
if err := util.ValidateNoDuplicateHeights(heights); err != nil {
253+
return nil, fmt.Errorf("RPC returned invalid block list: %w", err)
254+
}
255+
247256
return blocks, nil
248257
}
249258

eotsmanager/localmanager.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,15 @@ func (lm *LocalEOTSManager) SignBatchEOTS(req *SignBatchEOTSRequest) ([]SignData
313313

314314
eotsPk, chainID := req.UID, req.ChainID
315315

316+
heights := make([]uint64, 0, len(req.SignRequest))
317+
for _, request := range req.SignRequest {
318+
heights = append(heights, request.Height)
319+
}
320+
321+
if err := util.ValidateNoDuplicateHeights(heights); err != nil {
322+
return nil, fmt.Errorf("%w: %w", eotstypes.ErrDuplicateHeight, err)
323+
}
324+
316325
keyName, err := lm.es.GetEOTSKeyName(eotsPk)
317326
if err != nil {
318327
return nil, fmt.Errorf("failed to get EOTS key name: %w", err)
@@ -323,12 +332,7 @@ func (lm *LocalEOTSManager) SignBatchEOTS(req *SignBatchEOTSRequest) ([]SignData
323332
return nil, fmt.Errorf("failed to get EOTS private key: %w", err)
324333
}
325334

326-
// Extract all heights for batch lookup
327-
heights := make([]uint64, 0, len(req.SignRequest))
328-
for _, request := range req.SignRequest {
329-
heights = append(heights, request.Height)
330-
}
331-
335+
// Use heights extracted above for validation
332336
existingRecords, err := lm.es.GetSignRecordsBatch(eotsPk, chainID, heights)
333337
if err != nil {
334338
return nil, fmt.Errorf("failed to get existing sign records: %w", err)

eotsmanager/service/rpcserver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/babylonlabs-io/finality-provider/eotsmanager/config"
1515
"github.com/babylonlabs-io/finality-provider/eotsmanager/proto"
1616
"github.com/babylonlabs-io/finality-provider/eotsmanager/types"
17+
"github.com/babylonlabs-io/finality-provider/util"
1718
)
1819

1920
// rpcServer is the main RPC server for the EOTS daemon that handles
@@ -122,6 +123,17 @@ func (r *rpcServer) SignSchnorrSig(_ context.Context, req *proto.SignSchnorrSigR
122123
// SignBatchEOTS signs multiple EOTS in batch
123124
func (r *rpcServer) SignBatchEOTS(_ context.Context, req *proto.SignBatchEOTSRequest) (
124125
*proto.SignBatchEOTSResponse, error) {
126+
// Validate no duplicate heights before forwarding to manager (defense-in-depth)
127+
heights := make([]uint64, len(req.SignRequests))
128+
for i, signReq := range req.SignRequests {
129+
heights[i] = signReq.Height
130+
}
131+
132+
if err := util.ValidateNoDuplicateHeights(heights); err != nil {
133+
return nil, status.Error(codes.InvalidArgument, //nolint:wrapcheck
134+
fmt.Sprintf("duplicate height in batch: %v", err))
135+
}
136+
125137
signRequests := make([]*eotsmanager.SignDataRequest, len(req.SignRequests))
126138
for i, signReq := range req.SignRequests {
127139
signRequests[i] = &eotsmanager.SignDataRequest{

eotsmanager/types/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ import "errors"
66
var (
77
ErrFinalityProviderAlreadyExisted = errors.New("the finality provider has already existed")
88
ErrDoubleSign = errors.New("double sign")
9+
ErrDuplicateHeight = errors.New("duplicate height in batch request")
910
)

util/validation.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//nolint:revive
2+
package util
3+
4+
import "fmt"
5+
6+
// HasDuplicateHeights checks if the provided slice of heights contains any duplicates.
7+
// Returns (true, duplicateHeight) if a duplicate is found, (false, 0) otherwise.
8+
// Uses a map for O(n) time complexity.
9+
//
10+
// This function is critical for preventing EOTS private key extraction attacks.
11+
// Signing the same height twice with different messages allows an attacker to
12+
// mathematically extract the private key through EOTS vulnerability.
13+
func HasDuplicateHeights(heights []uint64) (bool, uint64) {
14+
seen := make(map[uint64]struct{}, len(heights))
15+
for _, height := range heights {
16+
if _, exists := seen[height]; exists {
17+
return true, height
18+
}
19+
seen[height] = struct{}{}
20+
}
21+
22+
return false, 0
23+
}
24+
25+
// ValidateNoDuplicateHeights returns an error if duplicate heights are found in the slice.
26+
// This validation is essential for preventing EOTS key extraction via duplicate signing.
27+
func ValidateNoDuplicateHeights(heights []uint64) error {
28+
if hasDup, dupHeight := HasDuplicateHeights(heights); hasDup {
29+
return fmt.Errorf("duplicate height detected: %d", dupHeight)
30+
}
31+
32+
return nil
33+
}

util/validation_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//nolint:revive
2+
package util_test
3+
4+
import (
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/babylonlabs-io/finality-provider/util"
10+
)
11+
12+
func TestHasDuplicateHeights(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
name string
17+
heights []uint64
18+
expectDup bool
19+
dupHeight uint64
20+
}{
21+
{
22+
name: "no duplicates",
23+
heights: []uint64{1, 2, 3, 4, 5},
24+
expectDup: false,
25+
dupHeight: 0,
26+
},
27+
{
28+
name: "duplicate at start",
29+
heights: []uint64{1, 1, 3},
30+
expectDup: true,
31+
dupHeight: 1,
32+
},
33+
{
34+
name: "duplicate in middle",
35+
heights: []uint64{1, 2, 2, 3},
36+
expectDup: true,
37+
dupHeight: 2,
38+
},
39+
{
40+
name: "duplicate at end",
41+
heights: []uint64{1, 2, 3, 3},
42+
expectDup: true,
43+
dupHeight: 3,
44+
},
45+
{
46+
name: "empty slice",
47+
heights: []uint64{},
48+
expectDup: false,
49+
dupHeight: 0,
50+
},
51+
{
52+
name: "single element",
53+
heights: []uint64{42},
54+
expectDup: false,
55+
dupHeight: 0,
56+
},
57+
{
58+
name: "two identical elements",
59+
heights: []uint64{100, 100},
60+
expectDup: true,
61+
dupHeight: 100,
62+
},
63+
{
64+
name: "non-consecutive unique heights",
65+
heights: []uint64{10, 20, 30, 15, 25},
66+
expectDup: false,
67+
dupHeight: 0,
68+
},
69+
{
70+
name: "large numbers no duplicates",
71+
heights: []uint64{1000000, 2000000, 3000000},
72+
expectDup: false,
73+
dupHeight: 0,
74+
},
75+
{
76+
name: "large numbers with duplicate",
77+
heights: []uint64{1000000, 2000000, 1000000},
78+
expectDup: true,
79+
dupHeight: 1000000,
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
hasDup, dupHeight := util.HasDuplicateHeights(tt.heights)
88+
require.Equal(t, tt.expectDup, hasDup)
89+
if tt.expectDup {
90+
require.Equal(t, tt.dupHeight, dupHeight)
91+
} else {
92+
require.Equal(t, uint64(0), dupHeight)
93+
}
94+
})
95+
}
96+
}
97+
98+
func TestValidateNoDuplicateHeights(t *testing.T) {
99+
t.Parallel()
100+
101+
tests := []struct {
102+
name string
103+
heights []uint64
104+
expectErr bool
105+
}{
106+
{
107+
name: "valid unique heights",
108+
heights: []uint64{1, 2, 3, 4, 5},
109+
expectErr: false,
110+
},
111+
{
112+
name: "invalid with duplicates",
113+
heights: []uint64{1, 2, 2, 3},
114+
expectErr: true,
115+
},
116+
{
117+
name: "empty is valid",
118+
heights: []uint64{},
119+
expectErr: false,
120+
},
121+
{
122+
name: "single element is valid",
123+
heights: []uint64{1},
124+
expectErr: false,
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
t.Parallel()
131+
132+
err := util.ValidateNoDuplicateHeights(tt.heights)
133+
if tt.expectErr {
134+
require.Error(t, err)
135+
require.Contains(t, err.Error(), "duplicate height detected")
136+
} else {
137+
require.NoError(t, err)
138+
}
139+
})
140+
}
141+
}

0 commit comments

Comments
 (0)