Skip to content

Commit 8ef3356

Browse files
libotonyvanja-vechainpaologalligit
authored
enforce low S in transaction signature only in tx pool (#1575)
Co-authored-by: vanjatomic <vanja.tomic@vechain.org> Co-authored-by: paologalligit <paolo.galli@vechain.org>
1 parent d403ed0 commit 8ef3356

File tree

8 files changed

+356
-13
lines changed

8 files changed

+356
-13
lines changed

api/transactions/transactions_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,10 @@ func sendTxWithBadFormat(t *testing.T) {
323323
}
324324

325325
func sendTxThatCannotBeAcceptedInLocalMempool(t *testing.T) {
326-
tx := tx.NewBuilder(tx.TypeLegacy).Build()
327-
rlpTx, err := tx.MarshalBinary()
326+
trx := tx.NewBuilder(tx.TypeLegacy).Build()
327+
trx = tx.MustSign(trx, genesis.DevAccounts()[0].PrivateKey)
328+
329+
rlpTx, err := trx.MarshalBinary()
328330
if err != nil {
329331
t.Fatal(err)
330332
}

comm/handle_rpc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"strconv"
1111
"time"
1212

13+
"github.com/vechain/thor/v2/txpool"
14+
1315
"github.com/ethereum/go-ethereum/rlp"
1416
"github.com/pkg/errors"
1517

@@ -20,6 +22,8 @@ import (
2022
"github.com/vechain/thor/v2/tx"
2123
)
2224

25+
var maxTxSize = uint32(txpool.MaxTxSize + 1024)
26+
2327
// peer will be disconnected if error returned
2428
func (c *Communicator) handleRPC(peer *Peer, msg *p2p.Msg, write func(any), txsToSync *txsToSync) (err error) {
2529
log := peer.logger.New("msg", proto.MsgName(msg.Code))
@@ -66,6 +70,9 @@ func (c *Communicator) handleRPC(peer *Peer, msg *p2p.Msg, write func(any), txsT
6670
}
6771
write(&struct{}{})
6872
case proto.MsgNewTx:
73+
if msg.Size > maxTxSize {
74+
return errors.New("payload size: exceeds limit")
75+
}
6976
var newTx *tx.Transaction
7077
if err := msg.Decode(&newTx); err != nil {
7178
return errors.WithMessage(err, "decode msg")

comm/handle_rpc_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2025 The VeChainThor developers
2+
3+
// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying
4+
// file LICENSE or <https://www.gnu.org/licenses/lgpl-3.0.html>
5+
6+
package comm
7+
8+
import (
9+
"bytes"
10+
"math/big"
11+
"testing"
12+
"time"
13+
14+
"github.com/ethereum/go-ethereum/rlp"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
18+
"github.com/vechain/thor/v2/comm/proto"
19+
"github.com/vechain/thor/v2/genesis"
20+
"github.com/vechain/thor/v2/p2p"
21+
"github.com/vechain/thor/v2/p2p/discover"
22+
"github.com/vechain/thor/v2/test/testchain"
23+
"github.com/vechain/thor/v2/thor"
24+
"github.com/vechain/thor/v2/tx"
25+
"github.com/vechain/thor/v2/txpool"
26+
)
27+
28+
func TestHandleRPC_MsgNewTx(t *testing.T) {
29+
chain, err := testchain.NewWithFork(&thor.SoloFork, 180)
30+
require.NoError(t, err)
31+
repo := chain.Repo()
32+
33+
pool := txpool.New(repo, chain.Stater(), txpool.Options{
34+
Limit: 10000,
35+
LimitPerAccount: 16,
36+
MaxLifetime: 10 * time.Minute,
37+
}, &thor.SoloFork)
38+
defer pool.Close()
39+
40+
comm := New(repo, pool)
41+
peer := newPeer(p2p.NewPeer(discover.NodeID{}, "test", nil), stubMsgReadWriter{})
42+
43+
to, _ := thor.ParseAddress("0x7567d83b7b8d80addcb281a71d54fc7b3364ffed")
44+
chainTag := repo.ChainTag()
45+
testTx := tx.NewBuilder(tx.TypeLegacy).
46+
ChainTag(chainTag).
47+
BlockRef(tx.NewBlockRef(0)).
48+
Expiration(100).
49+
GasPriceCoef(0).
50+
Gas(21000).
51+
Nonce(1).
52+
Clause(tx.NewClause(&to).WithValue(big.NewInt(1))).
53+
Build()
54+
testTx = tx.MustSign(testTx, genesis.DevAccounts()[0].PrivateKey)
55+
txHash := testTx.Hash()
56+
txID := testTx.ID()
57+
58+
txData, err := rlp.EncodeToBytes(testTx)
59+
require.NoError(t, err)
60+
61+
t.Run("valid transaction", func(t *testing.T) {
62+
writeCalled := false
63+
var writtenData any
64+
65+
write := func(data any) {
66+
writeCalled = true
67+
writtenData = data
68+
}
69+
70+
msg := &p2p.Msg{
71+
Code: proto.MsgNewTx,
72+
Size: uint32(len(txData)),
73+
Payload: bytes.NewReader(txData),
74+
}
75+
76+
txsToSync := &txsToSync{}
77+
78+
err := comm.handleRPC(peer, msg, write, txsToSync)
79+
assert.NoError(t, err)
80+
81+
assert.True(t, peer.IsTransactionKnown(txHash), "transaction should be marked on peer")
82+
83+
addedTx := pool.Get(txID)
84+
assert.NotNil(t, addedTx, "transaction should be added to pool")
85+
if addedTx != nil {
86+
assert.Equal(t, testTx.Hash(), addedTx.Hash(), "added transaction should match")
87+
}
88+
89+
assert.True(t, writeCalled, "write function should be called")
90+
assert.Equal(t, &struct{}{}, writtenData, "write should be called with empty struct")
91+
})
92+
93+
t.Run("transaction exceeds size limit", func(t *testing.T) {
94+
writeCalled := false
95+
96+
write := func(data any) {
97+
writeCalled = true
98+
}
99+
100+
msg := &p2p.Msg{
101+
Code: proto.MsgNewTx,
102+
Size: maxTxSize + 1,
103+
Payload: bytes.NewReader(txData),
104+
}
105+
106+
txsToSync := &txsToSync{}
107+
108+
err := comm.handleRPC(peer, msg, write, txsToSync)
109+
assert.Error(t, err)
110+
assert.Contains(t, err.Error(), "payload size: exceeds limit")
111+
112+
assert.False(t, writeCalled, "write should not be called on error")
113+
})
114+
115+
t.Run("decode error", func(t *testing.T) {
116+
writeCalled := false
117+
118+
write := func(data any) {
119+
writeCalled = true
120+
}
121+
122+
invalidData := []byte{0x01, 0x02, 0x03}
123+
msg := &p2p.Msg{
124+
Code: proto.MsgNewTx,
125+
Size: uint32(len(invalidData)),
126+
Payload: bytes.NewReader(invalidData),
127+
}
128+
129+
txsToSync := &txsToSync{}
130+
131+
err := comm.handleRPC(peer, msg, write, txsToSync)
132+
assert.Error(t, err)
133+
assert.Contains(t, err.Error(), "decode msg")
134+
135+
assert.False(t, writeCalled, "write should not be called on error")
136+
})
137+
138+
t.Run("transaction at size limit boundary", func(t *testing.T) {
139+
writeCalled := false
140+
var writtenData any
141+
142+
write := func(data any) {
143+
writeCalled = true
144+
writtenData = data
145+
}
146+
147+
msg := &p2p.Msg{
148+
Code: proto.MsgNewTx,
149+
Size: maxTxSize,
150+
Payload: bytes.NewReader(txData),
151+
}
152+
153+
txsToSync := &txsToSync{}
154+
155+
err := comm.handleRPC(peer, msg, write, txsToSync)
156+
assert.NoError(t, err)
157+
158+
assert.True(t, writeCalled, "write function should be called")
159+
assert.Equal(t, &struct{}{}, writtenData, "write should be called with empty struct")
160+
})
161+
}

p2p/peer_error.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ var discReasonToString = [...]string{
8989
}
9090

9191
func (d DiscReason) String() string {
92-
if len(discReasonToString) < int(d) {
92+
if len(discReasonToString) <= int(d) || discReasonToString[d] == "" {
9393
return fmt.Sprintf("unknown disconnect reason %d", d)
9494
}
9595
return discReasonToString[d]

tx/transaction.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,21 @@ import (
2626

2727
var (
2828
ErrTxTypeNotSupported = errors.New("transaction type not supported")
29+
ErrHighSInSignature = errors.New("invalid signature: S value is out of range")
2930

3031
errIntrinsicGasOverflow = errors.New("intrinsic gas overflow")
3132
errShortTypedTx = errors.New("typed transaction too short")
33+
34+
// secp256k1HalfN is N/2 precomputed as 32-byte big-endian array for efficient low-s check.
35+
// N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
36+
// N/2 = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
37+
secp256k1HalfN = func() [32]byte {
38+
n := crypto.S256().Params().N
39+
halfN := new(big.Int).Rsh(n, 1)
40+
var result [32]byte
41+
halfN.FillBytes(result[:])
42+
return result
43+
}()
3244
)
3345

3446
type Type = byte
@@ -627,6 +639,32 @@ func (t *Transaction) validateSignatureLength() error {
627639
return nil
628640
}
629641

642+
// EnforceSignatureLowS checks that the S value in the signature are <= secp256k1 N/2.
643+
// This is not required for consensus, but a protection against signature malleability.
644+
func (t *Transaction) EnforceSignatureLowS() error {
645+
if err := t.validateSignatureLength(); err != nil {
646+
return err
647+
}
648+
649+
sig := t.body.signature()
650+
651+
// Check first signature's S (bytes 32:64)
652+
if len(sig) == 65 {
653+
if bytes.Compare(sig[32:64], secp256k1HalfN[:]) > 0 {
654+
return ErrHighSInSignature
655+
}
656+
}
657+
658+
// Check delegator signature's S if present (bytes 97:129)
659+
if len(sig) == 130 {
660+
if bytes.Compare(sig[65+32:65+64], secp256k1HalfN[:]) > 0 {
661+
return ErrHighSInSignature
662+
}
663+
}
664+
665+
return nil
666+
}
667+
630668
// IntrinsicGas calculate intrinsic gas cost for tx with such clauses.
631669
func IntrinsicGas(clauses ...*Clause) (uint64, error) {
632670
if len(clauses) == 0 {

tx/transaction_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,3 +791,57 @@ func TestOverallGasPrice_LegacyWithProvedWork(t *testing.T) {
791791
res := trx.OverallGasPrice(baseGasPrice, provedWork)
792792
assert.NotNil(t, res)
793793
}
794+
795+
var (
796+
// precomputed secp256k1 N/2 as bytes for benchmark
797+
benchHalfNBytes = func() [32]byte {
798+
n := crypto.S256().Params().N
799+
halfN := new(big.Int).Rsh(n, 1)
800+
var result [32]byte
801+
halfN.FillBytes(result[:])
802+
return result
803+
}()
804+
// precomputed as big.Int for benchmark
805+
benchHalfNBigInt = new(big.Int).Rsh(crypto.S256().Params().N, 1)
806+
)
807+
808+
func BenchmarkLowSCheck(b *testing.B) {
809+
// Generate a realistic S value (32 bytes, close to halfN)
810+
sBytes := make([]byte, 32)
811+
copy(sBytes, []byte{
812+
0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
813+
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
814+
0x5D, 0x57, 0x6E, 0x73, 0x57, 0xA4, 0x50, 0x1D,
815+
0xDF, 0xE9, 0x2F, 0x46, 0x68, 0x1B, 0x20, 0x00,
816+
})
817+
818+
b.Run("BigInt", func(b *testing.B) {
819+
halfN := benchHalfNBigInt
820+
b.ReportAllocs()
821+
b.ResetTimer()
822+
for b.Loop() {
823+
s := new(big.Int).SetBytes(sBytes)
824+
_ = s.Cmp(halfN) > 0
825+
}
826+
})
827+
828+
b.Run("BigInt_Reuse", func(b *testing.B) {
829+
halfN := benchHalfNBigInt
830+
s := new(big.Int)
831+
b.ReportAllocs()
832+
b.ResetTimer()
833+
for b.Loop() {
834+
s.SetBytes(sBytes)
835+
_ = s.Cmp(halfN) > 0
836+
}
837+
})
838+
839+
b.Run("BytesCompare", func(b *testing.B) {
840+
halfN := benchHalfNBytes[:]
841+
b.ReportAllocs()
842+
b.ResetTimer()
843+
for b.Loop() {
844+
_ = bytes.Compare(sBytes, halfN) > 0
845+
}
846+
})
847+
}

txpool/tx_pool.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,6 @@ func (p *TxPool) add(newTx *tx.Transaction, rejectNonExecutable bool, localSubmi
240240
metricBadTxGauge().AddWithLabel(1, map[string]string{"source": source})
241241
}
242242
}()
243-
txTypeString := "Legacy"
244-
if newTx.Type() == tx.TypeDynamicFee {
245-
txTypeString = "DynamicFee"
246-
}
247243

248244
if p.all.ContainsHash(newTx.Hash()) {
249245
// tx already in the pool
@@ -274,7 +270,13 @@ func (p *TxPool) add(newTx *tx.Transaction, rejectNonExecutable bool, localSubmi
274270
}
275271

276272
atomic.AddUint32(&p.addedAfterWash, 1)
273+
274+
txTypeString := "Legacy"
275+
if newTx.Type() == tx.TypeDynamicFee {
276+
txTypeString = "DynamicFee"
277+
}
277278
metricTxPoolGauge().AddWithLabel(1, map[string]string{"source": source, "type": txTypeString})
279+
278280
return nil
279281
}
280282

@@ -697,6 +699,10 @@ func (p *TxPool) Len() int {
697699

698700
// validateTxBasics runs static validation on a transaction.
699701
func (p *TxPool) validateTxBasics(trx *tx.Transaction) error {
702+
if err := trx.EnforceSignatureLowS(); err != nil {
703+
return badTxError{err.Error()}
704+
}
705+
700706
if trx.ChainTag() != p.repo.ChainTag() {
701707
return badTxError{"chain tag mismatch"}
702708
}

0 commit comments

Comments
 (0)