Skip to content

Commit

Permalink
Add ECRecover EVM precompile (#504)
Browse files Browse the repository at this point in the history
* [ecdsa] pull message hashing out of `impl` procs

Done so that future public key recovery can simply call `verifyImpl`
to verify public key is found (signImpl changed to match).

* [ecdsa] implement public key recovery

* [tests] add test case to recover public key from sig&msgHash

* [ecdsa] allow customizing the hash function to be used

ECDSA over secp256k1 commonly uses both SHA256 (e.g. Bitcoin) and
Keccak256 (e.g. Ethereum). Other combinations may also exist.

We default to SHA256 for the time being.

* [ecdsa] add `recoverPubkey` which directly takes a hash digest as scalar

ECRecover provides the message hash and not the message. We need an
API to pass that directly to the internal ECDSA procedure.

We export the impl `vartime` routine for that purpose. We could
alternatively also import that file using `{.all.}`.

* [precompiles] add ECRecover Ethereum precompile

We extend the CttEVMStatus enum by two further elements. One for an
invalid signature in ECRecover and another for an invalid `v` value.

* [tests] add test case for ECRecover

* Update constantine/signatures/ecdsa.nim

Co-authored-by: Mamy Ratsimbazafy <[email protected]>

* Update constantine/signatures/ecdsa.nim

Co-authored-by: Mamy Ratsimbazafy <[email protected]>

* Update constantine/signatures/ecdsa.nim

Co-authored-by: Mamy Ratsimbazafy <[email protected]>

* [precompiles] remove invalid V enum field, invalid -> malformed sig

* [ecdsa] rename ECDSA over secp256k1 file to eth specific

* [ecdsa] remove hash from Eth ECDSA file, specific to Eth now

* [tests] update the OpenSSL wrapper signing function to use Keccak256

* [ecdsa] name `recoverPubkey` -> `recoverPubkeyFromDigest` for variant

Given that we generate a C API from the code, we need to differentiate
the function names for the types. The default takes a message and this
variant takes a digest (as used in Ethereum's precompile for ECRecover).

* take out ECDSA test requiring OpenSSL v3.3 or higher

---------

Co-authored-by: Mamy Ratsimbazafy <[email protected]>
  • Loading branch information
Vindaar and mratsim authored Jan 13, 2025
1 parent ef2a91a commit 9dd1205
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 43 deletions.
3 changes: 2 additions & 1 deletion constantine.nimble
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,8 @@ const testDesc: seq[tuple[path: string, useGMP: bool]] = @[
("tests/t_ethereum_verkle_ipa_primitives.nim", false),

# Signatures
("tests/ecdsa/t_ecdsa_verify_openssl.nim", false),
# NOTE: Requires OpenSSL version >=v3.3 for to Keccak256 support
# ("tests/ecdsa/t_ecdsa_verify_openssl.nim", false),

# Proof systems
# ----------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
import
constantine/zoo_exports,
constantine/signatures/ecdsa,
constantine/hashes/h_sha256,
constantine/hashes,
constantine/named/algebras,
constantine/math/elliptic/[ec_shortweierstrass_affine],
constantine/math/[arithmetic, ec_shortweierstrass],
constantine/platforms/[abstractions, views]

export NonceSampler

const prefix_ffi = "ctt_ecdsa_secp256k1_"
const prefix_ffi = "ctt_eth_ecdsa"
type
SecretKey* {.byref, exportc: prefix_ffi & "seckey".} = object
## A Secp256k1 secret key
Expand Down Expand Up @@ -51,18 +51,48 @@ proc sign*(sig: var Signature,
## Sign `message` using `secretKey` and store the signature in `sig`. The nonce
## will either be randomly sampled `nsRandom` or deterministically calculated according
## to RFC6979 (`nsRfc6979`)
sig.coreSign(secretKey.raw, message, sha256, nonceSampler)
sig.coreSign(secretKey.raw, message, keccak256, nonceSampler)

proc verify*(
publicKey: PublicKey,
message: openArray[byte],
signature: Signature
): bool {.libPrefix: prefix_ffi, genCharAPI.} =
## Verify `signature` using `publicKey` for `message`.
result = publicKey.raw.coreVerify(message, signature, sha256)
result = publicKey.raw.coreVerify(message, signature, keccak256)

func derive_pubkey*(public_key: var PublicKey, secret_key: SecretKey) {.libPrefix: prefix_ffi.} =
## Derive the public key matching with a secret key
##
## The secret_key MUST be validated
public_key.raw.derivePubkey(secret_key.raw)

proc recoverPubkey*(
publicKey: var PublicKey,
message: openArray[byte],
signature: Signature,
evenY: bool
) {.libPrefix: prefix_ffi, genCharAPI.} =
## Verify `signature` using `publicKey` for `message`.
##
## `evenY == true` returns the public key corresponding to the
## even `y` coordinate of the `R` point.
publicKey.raw.recoverPubkey(signature, message, evenY, keccak256)

proc recoverPubkeyFromDigest*(
publicKey: var PublicKey,
msgHash: Fr[Secp256k1],
signature: Signature,
evenY: bool
) {.libPrefix: prefix_ffi.} =
## Verify `signature` using `publicKey` for the given message digest
## given as a scalar in the field `Fr[Secp256k1]`.
##
## `evenY == true` returns the public key corresponding to the
## even `y` coordinate of the `R` point.
##
## As this overload works directly with a message hash as a scalar,
## it requires no hash function. Internally, it also calls the
## `verify` implementation, which already takes a scalar and thus
## requires no hash function there either.
publicKey.raw.recoverPubkeyImpl_vartime(signature, msgHash, evenY)
80 changes: 79 additions & 1 deletion constantine/ethereum_evm_precompiles.nim
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import
./hash_to_curve/hash_to_curve,
# For KZG point precompile
./ethereum_eip4844_kzg,
./serialization/codecs_status_codes
./serialization/codecs_status_codes,
# ECDSA for ECRecover
./eth_ecdsa_signatures

# For KZG point precompile
export EthereumKZGContext, TrustedSetupFormat, TrustedSetupStatus, trusted_setup_load, trusted_setup_delete
Expand All @@ -48,6 +50,7 @@ type
cttEVM_PointNotOnCurve
cttEVM_PointNotInSubgroup
cttEVM_VerificationFailure
cttEVM_MalformedSignature

func eth_evm_sha256*(r: var openArray[byte], inputs: openArray[byte]): CttEVMStatus {.libPrefix: prefix_ffi, meter.} =
## SHA256
Expand Down Expand Up @@ -1295,3 +1298,78 @@ func eth_evm_kzg_point_evaluation*(ctx: ptr EthereumKZGContext,
r.toOpenArray(32, 64-1).marshal(Fr[BLS12_381].getModulus(), bigEndian)

result = cttEVM_Success

import std / importutils # Alternatively make `r`, `s` visible or define setter or constructor
func eth_evm_ecrecover*(r: var openArray[byte],
input: openArray[byte]): CttEVMStatus {.libPrefix: prefix_ffi, meter.} =
## Attempts to recover the public key, which was used to sign the given `data`
## to obtain the given signature `sig`.
##
## If the signature is invalid, the result array `r` will contain the neutral
## element of the curve.
##
## Inputs:
## - `r`: Array of the recovered public key. An elliptic curve point in affine
## coordinates (`EC_ShortW_Aff[Fp[Secp256k1], G1]`).
## - `input`: The input data as an array of 128 bytes. The data is as follows:
## - 32 byte: `keccak256` digest of the message that was signed
## - 32 byte: `v`, decides if the even or odd coordinate in `R` was used
## - 32 byte: `r` of the signature, scalar `Fr[Secp256k1]`
## - 32 byte: `s` of the signature, scalar `Fr[Secp256k1]`
##
## Implementation follows Geth here:
## https://github.com/ethereum/go-ethereum/blob/341647f1865dab437a690dc1424ba71495de2dd8/core/vm/contracts.go#L243-L272
##
## and to a lesser extent the Ethereum Yellow Paper in appendix F:
## https://ethereum.github.io/yellowpaper/paper.pdf
##
## Internal Geth implementation in:
## https://github.com/ethereum/go-ethereum/blob/master/signer/core/signed_data.go#L292-L319
if len(input) != 128:
return cttEVM_InvalidInputSize

if len(r) != 32:
return cttEVM_InvalidOutputSize

# 1. construct message hash as scalar in field `Fr[Secp256k1]`
var msgBI {.noinit.}: BigInt[256]
msgBI.unmarshal(input.toOpenArray(0, 32-1), bigEndian)
var msgHash {.noinit.}: Fr[Secp256k1]
msgHash.fromBig(msgBI)

# 2. verify `v` data is valid
## XXX: Or construct a `BigInt[256]` instead and compare? (or compare with uint64s?)
for i in 32 ..< 63: # first 31 bytes must be zero for a valid `v`
if input[i] != byte 0:
return cttEVM_MalformedSignature
let v = input[63]
if v notin [byte 0, 1, 27, 28]:
return cttEVM_MalformedSignature
# 2a. determine if even or odd `y` coordinate
let evenY = v in [byte 0, 27] # 0 / 27 indicates `y` to be even, 1 / 28 odd

# 3. unmarshal signature data
var signature {.noinit.}: Signature
privateAccess(Signature)
var rSig {.noinit}, sSig {.noinit.}: BigInt[256]
rSig.unmarshal(input.toOpenArray(64, 96-1), bigEndian)
sSig.unmarshal(input.toOpenArray(96, 128-1), bigEndian)
signature.r = Fr[Secp256k1].fromBig(rSig)
signature.s = Fr[Secp256k1].fromBig(sSig)

# 4. perform pubkey recovery
var pubKey {.noinit.}: PublicKey
pubKey.recoverPubkeyFromDigest(msgHash, signature, evenY)

# 4. now calculate the Ethereum address of the public key (keccak256)
privateAccess(PublicKey)
var rawPubkey {.noinit.}: array[64, byte] # `[x, y]` coordinates of public key
rawPubkey.toOpenArray( 0, 32-1).marshal(pubKey.raw.x, bigEndian)
rawPubkey.toOpenArray(32, 64-1).marshal(pubKey.raw.y, bigEndian)
var dgst {.noinit.}: array[32, byte] # keccak256 digest
keccak256.hash(dgst, rawPubkey)

# 5. and effectively truncate to last 20 bytes of digest
r.rawCopy(12, dgst, 12, 20)

result = cttEVM_Success
2 changes: 1 addition & 1 deletion constantine/serialization/codecs_ecdsa_secp256k1.nim
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import
constantine/math/arithmetic/finite_fields,
constantine/math/elliptic/ec_shortweierstrass_affine,
constantine/math/io/io_bigints,
constantine/ecdsa_secp256k1
constantine/eth_ecdsa_signatures

import std / [strutils, base64, math, importutils]

Expand Down
Loading

0 comments on commit 9dd1205

Please sign in to comment.