Skip to content
Merged
122 changes: 122 additions & 0 deletions libevm/precompiles/p256verify/p256verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.

// Package p256verify implements an EVM precompile to verify P256 ECDSA
// signatures, as described in RIP-7212.
package p256verify

import (
"crypto/ecdsa"
"crypto/elliptic"
"math/big"

"github.com/ava-labs/libevm/params"
)

// Precompile implements ECDSA verification on the P256 curve, as defined by
// [RIP-7212].
//
// [RIP-7212]: https://github.com/ethereum/RIPs/blob/1f55794f65caa4c4bb2b8d9bda7d713b8c734157/RIPS/rip-7212.md
type Precompile struct{}

// RequiredGas returns [params.P256VerifyGas].
func (Precompile) RequiredGas([]byte) uint64 {
return params.P256VerifyGas
}

const (
wordLen = 32
inputLen = 5 * wordLen
)

type input [inputLen]byte

type index int

const (
hashPos index = iota * wordLen
rPos
sPos
xPos
yPos
)

// Run parses and verifies the signature. On success it returns a 32-byte
// big-endian representation of the number 1, otherwise it returns an empty
// slice. The returned error is always nil.
func (Precompile) Run(sig []byte) ([]byte, error) {
if len(sig) != inputLen || !(*input)(sig).verify() {
return nil, nil
}
return bigEndianOne(), nil
}

func bigEndianOne() []byte {
return []byte{wordLen - 1: 1}
}

func (in *input) verify() bool {
key, ok := in.pubkey()
if !ok {
return false
}
return ecdsa.Verify(key, in.word(hashPos), in.bigWord(rPos), in.bigWord(sPos))
}

func (in *input) pubkey() (*ecdsa.PublicKey, bool) {
x := in.bigWord(xPos)
y := in.bigWord(yPos)

// There is no need to explicitly check for the point at infinity because
// [elliptic.Curve] documentation states that it's not on the curve and the
// check would therefore be performed twice.
// See https://cs.opensource.google/go/go/+/refs/tags/go1.24.3:src/crypto/elliptic/nistec.go;l=132
curve := elliptic.P256()
if !curve.IsOnCurve(x, y) {
return nil, false
}
return &ecdsa.PublicKey{
Curve: curve,
X: x,
Y: y,
}, true
}

func (in *input) word(i index) []byte {
return in[i : i+wordLen]
}

func (in *input) bigWord(i index) *big.Int {
return new(big.Int).SetBytes(in.word(i))
}

// Pack packs the arguments into a byte slice compatible with [Precompile.Run].
// It does NOT perform any validation on its inputs and therefore may panic if,
// for example, a [big.Int] with >256 bits is received. Keys and signatures
// generated with [elliptic.GenerateKey] and [ecdsa.Sign] are valid inputs.
func Pack(hash [32]byte, r, s *big.Int, key *ecdsa.PublicKey) []byte {
var in input

copy(in.word(hashPos), hash[:])

r.FillBytes(in.word(rPos))
s.FillBytes(in.word(sPos))

key.X.FillBytes(in.word(xPos))
key.Y.FillBytes(in.word(yPos))

return in[:]
}
264 changes: 264 additions & 0 deletions libevm/precompiles/p256verify/p256verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.

package p256verify

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/asn1"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"slices"
"strings"
"testing"

"github.com/holiman/uint256"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core/vm"
"github.com/ava-labs/libevm/libevm"
"github.com/ava-labs/libevm/libevm/ethtest"
"github.com/ava-labs/libevm/libevm/hookstest"
"github.com/ava-labs/libevm/params"

_ "embed"
)

var _ vm.PrecompiledContract = Precompile{}

// ulerdoganTestCase is the test case from
// https://github.com/ulerdogan/go-ethereum/blob/cec0b058115282168c5afc5197de3f6b5479dc4a/core/vm/testdata/precompiles/p256Verify.json,
// copied under LGPL. See the respective commit for copyright and license
// information.
const ulerdoganTestCase = `4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4da73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff37618b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e`

//go:embed testdata/ecdsa_secp256r1_sha256_test.json
var wycheproofECDSASHA256 []byte

type testCase struct {
name string
in []byte
wantSuccess bool
}

func signAndPack(tb testing.TB, priv *ecdsa.PrivateKey, hash [32]byte) []byte {
tb.Helper()
r, s, err := ecdsa.Sign(rand.Reader, priv, hash[:])
require.NoError(tb, err, "ecdsa.Sign()")
return Pack(hash, r, s, &priv.PublicKey)
}

func TestPrecompile(t *testing.T) {
assert.Equal(t, params.P256VerifyGas, Precompile{}.RequiredGas(nil), "RequiredGas()")

tests := []testCase{
{
name: "empty_input",
},
{
name: "input_too_short",
in: make([]byte, inputLen-1),
},
{
name: "input_too_long",
in: make([]byte, inputLen+1),
},
{
name: "pub_key_at_infinity",
in: make([]byte, inputLen),
},
{
name: "pub_key_not_on_curve",
in: []byte{inputLen - 1: 1},
},
{
name: "ulerdogan",
in: common.Hex2Bytes(ulerdoganTestCase),
wantSuccess: true,
},
}

for range 50 {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "ecdsa.GenerateKey(elliptic.P256(), crypto/rand.Reader)")

for range 50 {
var toSign [32]byte
_, err := rand.Read(toSign[:])
require.NoErrorf(t, err, "crypto/rand.Read(%T)", toSign)

in := signAndPack(t, priv, toSign)
tests = append(tests, testCase{
name: "fuzz_valid",
in: in,
wantSuccess: true,
})
corrupt := slices.Clone(in)
corrupt[0]++ // different signed hash
tests = append(tests, testCase{
name: "fuzz_invalid",
in: corrupt,
})
}
}

tests = append(tests, wycheproofTestCases(t)...)
if t.Failed() {
return
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Precompile{}.Run(tt.in)
require.NoError(t, err, "Run() always returns nil, even on verification failure")

var want []byte
if tt.wantSuccess {
want = common.LeftPadBytes([]byte{1}, 32)
}
assert.Equal(t, want, got)
})
}
}

type jsonHex []byte

var _ json.Unmarshaler = (*jsonHex)(nil)

func (j *jsonHex) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
b, err := hex.DecodeString(s)
if err != nil {
return err
}
*j = b
return nil
}

func wycheproofTestCases(t *testing.T) []testCase {
t.Helper()

var raw struct {
Groups []struct {
Key struct {
X jsonHex `json:"wx"`
Y jsonHex `json:"wy"`
}
Tests []struct {
ID int `json:"tcId"`
Comment string
Preimage jsonHex `json:"msg"`
ASNSig jsonHex `json:"sig"`
Result string
} `json:"tests"`
} `json:"testGroups"`
}
require.NoError(t, json.Unmarshal(wycheproofECDSASHA256, &raw))

var cases []testCase
for _, group := range raw.Groups {
key := &ecdsa.PublicKey{
Curve: elliptic.P256(),
X: new(big.Int).SetBytes(group.Key.X),
Y: new(big.Int).SetBytes(group.Key.Y),
}

for _, test := range group.Tests {
t.Run(fmt.Sprintf("parse_test_%d", test.ID), func(t *testing.T) {
// Many of the invalid cases are due to ASN1-specific problems,
// which aren't of concern to us.
include := test.Result == "valid" ||
strings.Contains(test.Comment, "r or s") ||
strings.Contains(test.Comment, "r and s") ||
slices.Contains(
[]int{
// Special cases of r and/or s.
286, 294, 295, 303, 304, 340, 341,
342, 343, 356, 357, 358, 359,
},
test.ID,
)

include = include && !slices.Contains(
// These cases have negative r or s value(s) with the same
// absolute value(s) as valid signatures. Packing and then
// unpacking via [big.Int.Bytes] therefore converts them to
// the valid, positive values that pass verification and
// raise false-positive test errors.
[]int{133, 139, 140},
test.ID,
)
if !include {
return
}

var rs [2]*big.Int
rest, err := asn1.Unmarshal(test.ASNSig, &rs)
if err != nil || len(rest) > 0 {
return
}
if rs[0].BitLen() > 256 || rs[1].BitLen() > 256 {
return
}
cases = append(cases, testCase{
name: fmt.Sprintf("wycheproof_ecdsa_secp256r1_sha256_%d", test.ID),
in: Pack(sha256.Sum256(test.Preimage), rs[0], rs[1], key),
wantSuccess: test.Result == "valid",
})
})
}
}
t.Logf("%d Wycheproof cases", len(cases))
return cases
}

func BenchmarkPrecompile(b *testing.B) {
in := common.Hex2Bytes(ulerdoganTestCase)
var p Precompile

for range b.N {
// Explicitly drop return values to placate the linter. The error is
// always nil and the input is tested above.
_, _ = p.Run(in)
}
}

func TestViaEVM(t *testing.T) {
addr := common.Address{42}
hooks := hookstest.Stub{
PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{
addr: Precompile{},
},
}
hooks.Register(t)

_, evm := ethtest.NewZeroEVM(t)
in := common.Hex2Bytes(ulerdoganTestCase)

got, _, err := evm.Call(vm.AccountRef{}, addr, in, 25000, uint256.NewInt(0))
require.NoError(t, err)
assert.Equal(t, []byte{31: 1}, got)
}
1 change: 1 addition & 0 deletions libevm/precompiles/p256verify/testdata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Test vectors from Project Wycheproof; see [original source](https://github.com/C2SP/wycheproof/tree/4a6c2bf5dc4c0b67c770233ad33961ee653996a0) for license and copyright. No changes made.
Loading
Loading