Skip to content

Commit 2672fbd

Browse files
authored
feat: RIP-7212 precompile for P256 ECDSA verification (#184)
## Why this should be merged Implementation for [ACP-204](avalanche-foundation/ACPs#204). ## How this works Implements [RIP-7212](https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md) and an additional `Pack(hash,r,s,key)` function with precompile-compatible output. ## How this was tested Unit tests including fuzzing, test case from the proposed but unmerged geth implementation, and vectors from [Project Wycheproof](https://github.com/C2SP/wycheproof).
1 parent fd03f3a commit 2672fbd

File tree

5 files changed

+5305
-0
lines changed

5 files changed

+5305
-0
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2025 the libevm authors.
2+
//
3+
// The libevm additions to go-ethereum are free software: you can redistribute
4+
// them and/or modify them under the terms of the GNU Lesser General Public License
5+
// as published by the Free Software Foundation, either version 3 of the License,
6+
// or (at your option) any later version.
7+
//
8+
// The libevm additions are distributed in the hope that they will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
11+
// General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU Lesser General Public License
14+
// along with the go-ethereum library. If not, see
15+
// <http://www.gnu.org/licenses/>.
16+
17+
// Package p256verify implements an EVM precompile to verify P256 ECDSA
18+
// signatures, as described in RIP-7212.
19+
package p256verify
20+
21+
import (
22+
"crypto/ecdsa"
23+
"crypto/elliptic"
24+
"math/big"
25+
26+
"github.com/ava-labs/libevm/params"
27+
)
28+
29+
// Precompile implements ECDSA verification on the P256 curve, as defined by
30+
// [RIP-7212].
31+
//
32+
// [RIP-7212]: https://github.com/ethereum/RIPs/blob/1f55794f65caa4c4bb2b8d9bda7d713b8c734157/RIPS/rip-7212.md
33+
type Precompile struct{}
34+
35+
// RequiredGas returns [params.P256VerifyGas].
36+
func (Precompile) RequiredGas([]byte) uint64 {
37+
return params.P256VerifyGas
38+
}
39+
40+
const (
41+
wordLen = 32
42+
inputLen = 5 * wordLen
43+
)
44+
45+
type input [inputLen]byte
46+
47+
type index int
48+
49+
const (
50+
hashPos index = iota * wordLen
51+
rPos
52+
sPos
53+
xPos
54+
yPos
55+
)
56+
57+
// Run parses and verifies the signature. On success it returns a 32-byte
58+
// big-endian representation of the number 1, otherwise it returns an empty
59+
// slice. The returned error is always nil.
60+
func (Precompile) Run(sig []byte) ([]byte, error) {
61+
if len(sig) != inputLen || !(*input)(sig).verify() {
62+
return nil, nil
63+
}
64+
return bigEndianOne(), nil
65+
}
66+
67+
func bigEndianOne() []byte {
68+
return []byte{wordLen - 1: 1}
69+
}
70+
71+
func (in *input) verify() bool {
72+
key, ok := in.pubkey()
73+
if !ok {
74+
return false
75+
}
76+
return ecdsa.Verify(key, in.word(hashPos), in.bigWord(rPos), in.bigWord(sPos))
77+
}
78+
79+
func (in *input) pubkey() (*ecdsa.PublicKey, bool) {
80+
x := in.bigWord(xPos)
81+
y := in.bigWord(yPos)
82+
83+
// There is no need to explicitly check for the point at infinity because
84+
// [elliptic.Curve] documentation states that it's not on the curve and the
85+
// check would therefore be performed twice.
86+
// See https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/crypto/elliptic/nistec.go;l=132
87+
curve := elliptic.P256()
88+
if !curve.IsOnCurve(x, y) {
89+
return nil, false
90+
}
91+
return &ecdsa.PublicKey{
92+
Curve: curve,
93+
X: x,
94+
Y: y,
95+
}, true
96+
}
97+
98+
func (in *input) word(i index) []byte {
99+
return in[i : i+wordLen]
100+
}
101+
102+
func (in *input) bigWord(i index) *big.Int {
103+
return new(big.Int).SetBytes(in.word(i))
104+
}
105+
106+
// Pack packs the arguments into a byte slice compatible with [Precompile.Run].
107+
// It does NOT perform any validation on its inputs and therefore may panic if,
108+
// for example, a [big.Int] with >256 bits is received. Keys and signatures
109+
// generated with [elliptic.GenerateKey] and [ecdsa.Sign] are valid inputs.
110+
func Pack(hash [32]byte, r, s *big.Int, key *ecdsa.PublicKey) []byte {
111+
var in input
112+
113+
copy(in.word(hashPos), hash[:])
114+
115+
r.FillBytes(in.word(rPos))
116+
s.FillBytes(in.word(sPos))
117+
118+
key.X.FillBytes(in.word(xPos))
119+
key.Y.FillBytes(in.word(yPos))
120+
121+
return in[:]
122+
}
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright 2025 the libevm authors.
2+
//
3+
// The libevm additions to go-ethereum are free software: you can redistribute
4+
// them and/or modify them under the terms of the GNU Lesser General Public License
5+
// as published by the Free Software Foundation, either version 3 of the License,
6+
// or (at your option) any later version.
7+
//
8+
// The libevm additions are distributed in the hope that they will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
11+
// General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU Lesser General Public License
14+
// along with the go-ethereum library. If not, see
15+
// <http://www.gnu.org/licenses/>.
16+
17+
package p256verify
18+
19+
import (
20+
"crypto/ecdsa"
21+
"crypto/elliptic"
22+
"crypto/rand"
23+
"crypto/sha256"
24+
"encoding/asn1"
25+
"encoding/hex"
26+
"encoding/json"
27+
"fmt"
28+
"math/big"
29+
"slices"
30+
"strings"
31+
"testing"
32+
33+
"github.com/holiman/uint256"
34+
"github.com/stretchr/testify/assert"
35+
"github.com/stretchr/testify/require"
36+
37+
"github.com/ava-labs/libevm/common"
38+
"github.com/ava-labs/libevm/core/vm"
39+
"github.com/ava-labs/libevm/libevm"
40+
"github.com/ava-labs/libevm/libevm/ethtest"
41+
"github.com/ava-labs/libevm/libevm/hookstest"
42+
"github.com/ava-labs/libevm/params"
43+
44+
_ "embed"
45+
)
46+
47+
var _ vm.PrecompiledContract = Precompile{}
48+
49+
// ulerdoganTestCase is the test case from
50+
// https://github.com/ulerdogan/go-ethereum/blob/cec0b058115282168c5afc5197de3f6b5479dc4a/core/vm/testdata/precompiles/p256Verify.json,
51+
// copied under LGPL. See the respective commit for copyright and license
52+
// information.
53+
const ulerdoganTestCase = `4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4da73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff37618b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e`
54+
55+
//go:embed testdata/ecdsa_secp256r1_sha256_test.json
56+
var wycheproofECDSASHA256 []byte
57+
58+
type testCase struct {
59+
name string
60+
in []byte
61+
wantSuccess bool
62+
}
63+
64+
func signAndPack(tb testing.TB, priv *ecdsa.PrivateKey, hash [32]byte) []byte {
65+
tb.Helper()
66+
r, s, err := ecdsa.Sign(rand.Reader, priv, hash[:])
67+
require.NoError(tb, err, "ecdsa.Sign()")
68+
return Pack(hash, r, s, &priv.PublicKey)
69+
}
70+
71+
func TestPrecompile(t *testing.T) {
72+
assert.Equal(t, params.P256VerifyGas, Precompile{}.RequiredGas(nil), "RequiredGas()")
73+
74+
tests := []testCase{
75+
{
76+
name: "empty_input",
77+
},
78+
{
79+
name: "input_too_short",
80+
in: make([]byte, inputLen-1),
81+
},
82+
{
83+
name: "input_too_long",
84+
in: make([]byte, inputLen+1),
85+
},
86+
{
87+
name: "pub_key_at_infinity",
88+
in: make([]byte, inputLen),
89+
},
90+
{
91+
name: "pub_key_not_on_curve",
92+
in: []byte{inputLen - 1: 1},
93+
},
94+
{
95+
name: "ulerdogan",
96+
in: common.Hex2Bytes(ulerdoganTestCase),
97+
wantSuccess: true,
98+
},
99+
}
100+
101+
for range 50 {
102+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
103+
require.NoError(t, err, "ecdsa.GenerateKey(elliptic.P256(), crypto/rand.Reader)")
104+
105+
for range 50 {
106+
var toSign [32]byte
107+
_, err := rand.Read(toSign[:])
108+
require.NoErrorf(t, err, "crypto/rand.Read(%T)", toSign)
109+
110+
in := signAndPack(t, priv, toSign)
111+
tests = append(tests, testCase{
112+
name: "fuzz_valid",
113+
in: in,
114+
wantSuccess: true,
115+
})
116+
corrupt := slices.Clone(in)
117+
corrupt[0]++ // different signed hash
118+
tests = append(tests, testCase{
119+
name: "fuzz_invalid",
120+
in: corrupt,
121+
})
122+
}
123+
}
124+
125+
tests = append(tests, wycheproofTestCases(t)...)
126+
if t.Failed() {
127+
return
128+
}
129+
130+
for _, tt := range tests {
131+
t.Run(tt.name, func(t *testing.T) {
132+
got, err := Precompile{}.Run(tt.in)
133+
require.NoError(t, err, "Run() always returns nil, even on verification failure")
134+
135+
var want []byte
136+
if tt.wantSuccess {
137+
want = common.LeftPadBytes([]byte{1}, 32)
138+
}
139+
assert.Equal(t, want, got)
140+
})
141+
}
142+
}
143+
144+
type jsonHex []byte
145+
146+
var _ json.Unmarshaler = (*jsonHex)(nil)
147+
148+
func (j *jsonHex) UnmarshalJSON(data []byte) error {
149+
var s string
150+
if err := json.Unmarshal(data, &s); err != nil {
151+
return err
152+
}
153+
b, err := hex.DecodeString(s)
154+
if err != nil {
155+
return err
156+
}
157+
*j = b
158+
return nil
159+
}
160+
161+
func wycheproofTestCases(t *testing.T) []testCase {
162+
t.Helper()
163+
164+
var raw struct {
165+
Groups []struct {
166+
Key struct {
167+
X jsonHex `json:"wx"`
168+
Y jsonHex `json:"wy"`
169+
}
170+
Tests []struct {
171+
ID int `json:"tcId"`
172+
Comment string
173+
Preimage jsonHex `json:"msg"`
174+
ASNSig jsonHex `json:"sig"`
175+
Result string
176+
} `json:"tests"`
177+
} `json:"testGroups"`
178+
}
179+
require.NoError(t, json.Unmarshal(wycheproofECDSASHA256, &raw))
180+
181+
var cases []testCase
182+
for _, group := range raw.Groups {
183+
key := &ecdsa.PublicKey{
184+
Curve: elliptic.P256(),
185+
X: new(big.Int).SetBytes(group.Key.X),
186+
Y: new(big.Int).SetBytes(group.Key.Y),
187+
}
188+
189+
for _, test := range group.Tests {
190+
t.Run(fmt.Sprintf("parse_test_%d", test.ID), func(t *testing.T) {
191+
// Many of the invalid cases are due to ASN1-specific problems,
192+
// which aren't of concern to us.
193+
include := test.Result == "valid" ||
194+
strings.Contains(test.Comment, "r or s") ||
195+
strings.Contains(test.Comment, "r and s") ||
196+
slices.Contains(
197+
[]int{
198+
// Special cases of r and/or s.
199+
286, 294, 295, 303, 304, 340, 341,
200+
342, 343, 356, 357, 358, 359,
201+
},
202+
test.ID,
203+
)
204+
205+
include = include && !slices.Contains(
206+
// These cases have negative r or s value(s) with the same
207+
// absolute value(s) as valid signatures. Packing and then
208+
// unpacking via [big.Int.Bytes] therefore converts them to
209+
// the valid, positive values that pass verification and
210+
// raise false-positive test errors.
211+
[]int{133, 139, 140},
212+
test.ID,
213+
)
214+
if !include {
215+
return
216+
}
217+
218+
var rs [2]*big.Int
219+
rest, err := asn1.Unmarshal(test.ASNSig, &rs)
220+
if err != nil || len(rest) > 0 {
221+
return
222+
}
223+
if rs[0].BitLen() > 256 || rs[1].BitLen() > 256 {
224+
return
225+
}
226+
cases = append(cases, testCase{
227+
name: fmt.Sprintf("wycheproof_ecdsa_secp256r1_sha256_%d", test.ID),
228+
in: Pack(sha256.Sum256(test.Preimage), rs[0], rs[1], key),
229+
wantSuccess: test.Result == "valid",
230+
})
231+
})
232+
}
233+
}
234+
t.Logf("%d Wycheproof cases", len(cases))
235+
return cases
236+
}
237+
238+
func BenchmarkPrecompile(b *testing.B) {
239+
in := common.Hex2Bytes(ulerdoganTestCase)
240+
var p Precompile
241+
242+
for range b.N {
243+
// Explicitly drop return values to placate the linter. The error is
244+
// always nil and the input is tested above.
245+
_, _ = p.Run(in)
246+
}
247+
}
248+
249+
func TestViaEVM(t *testing.T) {
250+
addr := common.Address{42}
251+
hooks := hookstest.Stub{
252+
PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{
253+
addr: Precompile{},
254+
},
255+
}
256+
hooks.Register(t)
257+
258+
_, evm := ethtest.NewZeroEVM(t)
259+
in := common.Hex2Bytes(ulerdoganTestCase)
260+
261+
got, _, err := evm.Call(vm.AccountRef{}, addr, in, 25000, uint256.NewInt(0))
262+
require.NoError(t, err)
263+
assert.Equal(t, []byte{31: 1}, got)
264+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Test vectors from Project Wycheproof; see [original source](https://github.com/C2SP/wycheproof/tree/4a6c2bf5dc4c0b67c770233ad33961ee653996a0) for license and copyright. No changes made.

0 commit comments

Comments
 (0)