Skip to content

Commit d2a2590

Browse files
committed
fix: skip quadratic base58 decode outside the 64-byte signature length band
Only 64..88 base58 chars can decode to 64 bytes, so gating the decode on that band is output-equivalent. Also cap tx_hash on the unauthenticated InboundKeys query.
1 parent 83c145f commit d2a2590

4 files changed

Lines changed: 205 additions & 11 deletions

File tree

‎utils/canonical.go‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ const (
2020

2121
const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
2222

23+
// A base58-encoded 64-byte Solana signature is always 64..88 characters: 88 is
24+
// ceil(512 / log2(58)) for a full-range value, and 64 is the all-zero case
25+
// (each leading zero byte encodes as one '1'). Outside that band the decode can
26+
// never produce 64 bytes, so its result would be discarded — see
27+
// canonicalizeSolanaTxHash. mr-tron/base58's decoder is quadratic (for each of
28+
// n characters it walks ceil(n/4) limbs), so decoding attacker-supplied strings
29+
// only to throw the result away is an unmetered CPU sink on public query paths.
30+
const (
31+
solanaSigBase58MinLen = 64
32+
solanaSigBase58MaxLen = 88
33+
)
34+
2335
// CAIP2Namespace returns the namespace component of a CAIP-2 chain id
2436
// ("eip155:1" → "eip155"). Returns "" when the id has no namespace.
2537
func CAIP2Namespace(chain string) string {
@@ -119,8 +131,13 @@ func canonicalizeSolanaTxHash(s string) (string, error) {
119131
if strings.HasPrefix(canon, "0x") {
120132
return canon, nil
121133
}
122-
if raw, decErr := base58.Decode(canon); decErr == nil && len(raw) == 64 {
123-
return "0x" + hex.EncodeToString(raw), nil
134+
// Only attempt the decode for lengths that can actually yield 64 bytes.
135+
// This is output-equivalent for every possible input: a string outside the
136+
// band already falls through to `return canon` below, decode or not.
137+
if n := len(canon); n >= solanaSigBase58MinLen && n <= solanaSigBase58MaxLen {
138+
if raw, decErr := base58.Decode(canon); decErr == nil && len(raw) == 64 {
139+
return "0x" + hex.EncodeToString(raw), nil
140+
}
124141
}
125142
return canon, nil
126143
}

‎utils/canonical_test.go‎

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
package utils_test
22

33
import (
4+
"encoding/hex"
5+
"math/rand"
6+
"strings"
47
"testing"
8+
"time"
59

10+
"github.com/mr-tron/base58"
611
"github.com/stretchr/testify/require"
712

813
"github.com/pushchain/push-chain-node/utils"
914
)
1015

1116
const (
12-
eip55Addr = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
13-
lowerAddr = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
14-
upperAddr = "0X5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED"
15-
noPfxAddr = "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
16-
mixedHash = "0xB28F49668e7e76dc96D7aaBE5b7f63FEcfbd1c3574774c05e8204e749fd96fbd"
17-
lowerHash = "0xb28f49668e7e76dc96d7aabe5b7f63fecfbd1c3574774c05e8204e749fd96fbd"
18-
noPfxHash = "b28f49668e7e76dc96d7aabe5b7f63fecfbd1c3574774c05e8204e749fd96fbd"
19-
solPubkey = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
20-
solSig = "5j7s6NiJS3JAkvgkoc18WVAsiSaci2pxB2A6ueCJP4tprA2TFg9wSyTLeYouxPBJEMzJinENTkpA52YStRW5Dia7"
17+
eip55Addr = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
18+
lowerAddr = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
19+
upperAddr = "0X5AAEB6053F3E94C9B9A09F33669435E7EF1BEAED"
20+
noPfxAddr = "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"
21+
mixedHash = "0xB28F49668e7e76dc96D7aaBE5b7f63FEcfbd1c3574774c05e8204e749fd96fbd"
22+
lowerHash = "0xb28f49668e7e76dc96d7aabe5b7f63fecfbd1c3574774c05e8204e749fd96fbd"
23+
noPfxHash = "b28f49668e7e76dc96d7aabe5b7f63fecfbd1c3574774c05e8204e749fd96fbd"
24+
solPubkey = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
25+
solSig = "5j7s6NiJS3JAkvgkoc18WVAsiSaci2pxB2A6ueCJP4tprA2TFg9wSyTLeYouxPBJEMzJinENTkpA52YStRW5Dia7"
2126
)
2227

2328
func TestCanonicalizeEVMAddress_EquivalentEncodingsConverge(t *testing.T) {
@@ -134,3 +139,72 @@ func TestCAIP2Namespace(t *testing.T) {
134139
require.Equal(t, "solana", utils.CAIP2Namespace("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"))
135140
require.Equal(t, "", utils.CAIP2Namespace("no-colon"))
136141
}
142+
143+
// referenceSolanaTxHash reproduces the pre-fix behaviour for pure-base58 input:
144+
// decode unconditionally, convert only on an exact 64-byte result, otherwise
145+
// return the input untouched. The length band added in canonicalizeSolanaTxHash
146+
// must not change the result for any input.
147+
func referenceSolanaTxHash(s string) string {
148+
if raw, err := base58.Decode(s); err == nil && len(raw) == 64 {
149+
return "0x" + hex.EncodeToString(raw)
150+
}
151+
return s
152+
}
153+
154+
func TestCanonicalizeTxHashByNamespace_Solana_LengthBandIsOutputEquivalent(t *testing.T) {
155+
// Only 64..88 base58 chars can decode to exactly 64 bytes, so the band gate
156+
// is a pure performance change. Sweep across it — 63/64/88/89 are the edges.
157+
rng := rand.New(rand.NewSource(1))
158+
alphabet := []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
159+
160+
lengths := []int{1, 2, 31, 32, 43, 44, 63, 64, 65, 87, 88, 89, 90, 128, 200, 300}
161+
for n := 3; n < 63; n += 7 {
162+
lengths = append(lengths, n)
163+
}
164+
165+
for _, n := range lengths {
166+
for variant := 0; variant < 4; variant++ {
167+
b := make([]byte, n)
168+
for i := range b {
169+
switch variant {
170+
case 0:
171+
b[i] = '1' // all-zero decode: the short edge of the band
172+
case 1:
173+
b[i] = 'z' // largest digit: the long edge
174+
default:
175+
b[i] = alphabet[rng.Intn(len(alphabet))]
176+
}
177+
}
178+
in := string(b)
179+
require.Equal(t, referenceSolanaTxHash(in),
180+
utils.LenientCanonicalizeTxHash("solana:devnet", in),
181+
"length band changed the result for a %d-char input %q", n, in)
182+
}
183+
}
184+
}
185+
186+
func TestCanonicalizeTxHashByNamespace_Solana_RealSignatureStillConverges(t *testing.T) {
187+
// The band must not break the case it exists to serve: an 88-char base58
188+
// signature still folds to 0x-hex.
189+
got, err := utils.CanonicalizeTxHashByNamespace("solana:devnet", solSig)
190+
require.NoError(t, err)
191+
require.Equal(t, "0x", got[:2])
192+
require.Len(t, got, 2+128)
193+
}
194+
195+
func TestCanonicalizeTxHashByNamespace_Solana_OversizedInputDoesNotDecode(t *testing.T) {
196+
// F-2026-18821: mr-tron/base58 decoding is quadratic, and the result for an
197+
// out-of-band length is discarded. Before the fix a single 1e5-char decode
198+
// measured 4.5-29s (and InboundKeys does three of them); after, no decode
199+
// runs at all. The bound is loose enough not to flake on a busy CI box while
200+
// still failing hard on any return to O(n^2).
201+
huge := strings.Repeat("z", 100_000)
202+
203+
start := time.Now()
204+
got := utils.LenientCanonicalizeTxHash("solana:devnet", huge)
205+
elapsed := time.Since(start)
206+
207+
require.Equal(t, huge, got, "out-of-band input must pass through unchanged")
208+
require.Less(t, elapsed, time.Second,
209+
"oversized base58 tx_hash must not be decoded (took %s)", elapsed)
210+
}

‎x/uexecutor/keeper/query_keys.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,29 @@ import (
1212
"github.com/pushchain/push-chain-node/x/uexecutor/types"
1313
)
1414

15+
// maxQueryTxHashLen bounds the tx_hash accepted by the unauthenticated key
16+
// derivation queries. Longest real value is an 88-char base58 Solana signature.
17+
const maxQueryTxHashLen = 128
18+
1519
// InboundKeys derives the canonical UTX id and inbound ballot id for the given
1620
// inbound, applying the same canonicalization the vote path uses. Lets off-chain
1721
// validators read the keys from the chain instead of re-implementing the rules.
1822
func (k Querier) InboundKeys(goCtx context.Context, req *types.QueryInboundKeysRequest) (*types.QueryInboundKeysResponse, error) {
1923
if req == nil || req.Inbound == nil {
2024
return nil, status.Error(codes.InvalidArgument, "inbound is required")
2125
}
26+
// This endpoint is unauthenticated, reads no state and so consumes no gas.
27+
// Bound the one field that drives a decode (tx_hash) rather than trusting
28+
// the caller. The limit is far above any real hash — 88 chars for a base58
29+
// Solana signature, 66 for 0x-prefixed EVM — so it rejects only garbage.
30+
// Deliberately not applied to raw_payload / verification_data, which are
31+
// legitimately long, nor pushed down into utils.Canonicalize*: the vote
32+
// path must stay lenient (a malformed inbound still has to produce a UTX),
33+
// and changing shared canonicalization would alter ballot keys.
34+
if n := len(req.Inbound.TxHash); n > maxQueryTxHashLen {
35+
return nil, status.Errorf(codes.InvalidArgument,
36+
"tx_hash too long: %d chars (max %d)", n, maxQueryTxHashLen)
37+
}
2238

2339
inbound := *req.Inbound
2440
inbound.Canonicalize()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package keeper_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
"google.golang.org/grpc/codes"
10+
"google.golang.org/grpc/status"
11+
12+
"github.com/pushchain/push-chain-node/x/uexecutor/types"
13+
)
14+
15+
// solanaSig is a real 88-char base58 Solana signature (64 bytes).
16+
const solanaSig = "5j7s6NiJS3JAkvgkoc18WVAsiSaci2pxB2A6ueCJP4tprA2TFg9wSyTLeYouxPBJEMzJinENTkpA52YStRW5Dia7"
17+
18+
func TestInboundKeys_RejectsOversizedTxHash(t *testing.T) {
19+
// F-2026-18821: InboundKeys is unauthenticated, reads no state and so burns
20+
// no gas. It canonicalizes tx_hash three times (Canonicalize, then the UTX
21+
// and ballot key helpers), and base58 decoding is quadratic — a 1e5-char
22+
// hash cost tens of seconds of CPU per request before the fix.
23+
f := SetupTest(t)
24+
25+
huge := strings.Repeat("z", 100_000)
26+
27+
start := time.Now()
28+
_, err := f.queryServer.InboundKeys(f.ctx, &types.QueryInboundKeysRequest{
29+
Inbound: &types.Inbound{
30+
SourceChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
31+
TxHash: huge,
32+
LogIndex: "0",
33+
TxType: types.TxType_FUNDS,
34+
},
35+
})
36+
elapsed := time.Since(start)
37+
38+
require.Error(t, err)
39+
require.Equal(t, codes.InvalidArgument, status.Code(err))
40+
require.Contains(t, err.Error(), "tx_hash too long")
41+
require.Less(t, elapsed, time.Second, "oversized tx_hash must fail fast (took %s)", elapsed)
42+
}
43+
44+
func TestInboundKeys_AcceptsRealSolanaSignature(t *testing.T) {
45+
// The cap must not reject anything real: 88 chars is the longest a base58
46+
// 64-byte signature can be.
47+
f := SetupTest(t)
48+
49+
resp, err := f.queryServer.InboundKeys(f.ctx, &types.QueryInboundKeysRequest{
50+
Inbound: &types.Inbound{
51+
SourceChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
52+
TxHash: solanaSig,
53+
LogIndex: "0",
54+
TxType: types.TxType_FUNDS,
55+
},
56+
})
57+
58+
require.NoError(t, err)
59+
require.NotEmpty(t, resp.UtxId)
60+
require.NotEmpty(t, resp.BallotId)
61+
// Canonicalization folds the base58 signature into 0x-hex.
62+
require.Equal(t, "0x", resp.CanonicalInbound.TxHash[:2])
63+
require.Len(t, resp.CanonicalInbound.TxHash, 2+128)
64+
}
65+
66+
func TestInboundKeys_TxHashAtCapIsAccepted(t *testing.T) {
67+
// Boundary: exactly maxQueryTxHashLen (128) is allowed, 129 is not.
68+
f := SetupTest(t)
69+
70+
newReq := func(n int) *types.QueryInboundKeysRequest {
71+
return &types.QueryInboundKeysRequest{
72+
Inbound: &types.Inbound{
73+
SourceChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
74+
TxHash: strings.Repeat("z", n),
75+
LogIndex: "0",
76+
TxType: types.TxType_FUNDS,
77+
},
78+
}
79+
}
80+
81+
_, err := f.queryServer.InboundKeys(f.ctx, newReq(128))
82+
require.NoError(t, err, "128-char tx_hash is at the cap and must be accepted")
83+
84+
_, err = f.queryServer.InboundKeys(f.ctx, newReq(129))
85+
require.Error(t, err)
86+
require.Equal(t, codes.InvalidArgument, status.Code(err))
87+
}

0 commit comments

Comments
 (0)