From c589743e2b62e0492a787bb91e75ff31b6b4433a Mon Sep 17 00:00:00 2001 From: noot <36753753+noot@users.noreply.github.com> Date: Sat, 16 Jul 2022 21:45:41 +0000 Subject: [PATCH] ethereum: implement schnorr verification contract deployment and related crypto (#36) * basic schnorr verify working * add schnorr-verify as submodule * remove previous code * Misc Ethereum work which will probably be disregarded * add ecrecover hack test, worksgit add src/ * merge w develop * starting w/ rust-web3 * trying to use ethers * deploy_schnorr_verifier_contract finally working * modify EthereumHram to use 27/28 for point parity * updated address calc, solidity schnorr verify now working * add verify failure to test * update readme * move ethereum/ to coins/ * un fmt coins/monero * update .gitmodules * fix cargo paths * fix coins/monero * add #[allow(non_snake_case)] * un-fmt stuff * move crypto to coins/ethereum * move unit tests to ethereum/tests * remove js, build w ethers * update .gitignore * address comments * add q != 0 check * update contract param order * update contract license to AGPL * update ethereum-serai license to GPL and fmt * GPLv3 for ethereum-serai * AGPLv3 for ethereum-serai * actually fix license Co-authored-by: Luke Parker --- Cargo.toml | 1 + coins/ethereum/.gitignore | 3 + coins/ethereum/Cargo.toml | 28 ++++++++ coins/ethereum/README.md | 21 ++++++ coins/ethereum/build.rs | 15 ++++ coins/ethereum/contracts/Schnorr.sol | 36 ++++++++++ coins/ethereum/src/contract.rs | 52 ++++++++++++++ coins/ethereum/src/crypto.rs | 104 +++++++++++++++++++++++++++ coins/ethereum/src/lib.rs | 2 + coins/ethereum/tests/contract.rs | 60 ++++++++++++++++ coins/ethereum/tests/crypto.rs | 80 +++++++++++++++++++++ coins/ethereum/tests/mod.rs | 2 + processor/Cargo.toml | 6 +- 13 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 coins/ethereum/.gitignore create mode 100644 coins/ethereum/Cargo.toml create mode 100644 coins/ethereum/README.md create mode 100644 coins/ethereum/build.rs create mode 100644 coins/ethereum/contracts/Schnorr.sol create mode 100644 coins/ethereum/src/contract.rs create mode 100644 coins/ethereum/src/crypto.rs create mode 100644 coins/ethereum/src/lib.rs create mode 100644 coins/ethereum/tests/contract.rs create mode 100644 coins/ethereum/tests/crypto.rs create mode 100644 coins/ethereum/tests/mod.rs diff --git a/Cargo.toml b/Cargo.toml index ddbe2a01..51e9d6d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crypto/dleq", "crypto/frost", + "coins/ethereum", "coins/monero", "processor", diff --git a/coins/ethereum/.gitignore b/coins/ethereum/.gitignore new file mode 100644 index 00000000..6ff35861 --- /dev/null +++ b/coins/ethereum/.gitignore @@ -0,0 +1,3 @@ +# solidity build outputs +cache +artifacts diff --git a/coins/ethereum/Cargo.toml b/coins/ethereum/Cargo.toml new file mode 100644 index 00000000..4b8a29c5 --- /dev/null +++ b/coins/ethereum/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ethereum-serai" +version = "0.1.0" +description = "An Ethereum library supporting Schnorr signing and on-chain verification" +license = "AGPL-3.0-only" +authors = ["Luke Parker ", "Elizabeth Binks "] +edition = "2021" + +[dependencies] +thiserror = "1" +serde_json = "1.0" +serde = "1.0" +hex-literal = "0.3" + +ethers = { git = "https://github.com/gakonst/ethers-rs", features = ["abigen", "ethers-solc"] } +eyre = "0.6" + +k256 = { version = "0.11", features = ["arithmetic", "keccak256", "ecdsa"] } +frost = { package = "modular-frost", path = "../../crypto/frost", features = ["secp256k1"] } +sha3 = "0.10" +group = "0.12" + +[dev-dependencies] +rand = "0.8" +tokio = { version = "1.19", features = ["macros"] } + +[build-dependencies] +ethers-solc = { git = "https://github.com/gakonst/ethers-rs" } diff --git a/coins/ethereum/README.md b/coins/ethereum/README.md new file mode 100644 index 00000000..4f50c8c4 --- /dev/null +++ b/coins/ethereum/README.md @@ -0,0 +1,21 @@ +# Ethereum + +This package contains Ethereum-related functionality, specifically deploying and interacting with Serai contracts. + +## Requirements + +- anvil & solc & geth's abigen (see [here](https://github.com/gakonst/ethers-rs#running-the-tests)) + +## To test + +To compile contracts: +``` +cargo build +``` + +This places the compiled artifact into `artifacts/`. + +To run Rust tests (you must have compiled the contracts first): +``` +cargo test +``` \ No newline at end of file diff --git a/coins/ethereum/build.rs b/coins/ethereum/build.rs new file mode 100644 index 00000000..cd41215c --- /dev/null +++ b/coins/ethereum/build.rs @@ -0,0 +1,15 @@ +use ethers_solc::{Project, ProjectPathsConfig}; + +fn main() { + println!("cargo:rerun-if-changed=contracts/Schnorr.sol"); + + // configure the project with all its paths, solc, cache etc. + let project = Project::builder() + .paths(ProjectPathsConfig::hardhat(env!("CARGO_MANIFEST_DIR")).unwrap()) + .build() + .unwrap(); + project.compile().unwrap(); + + // Tell Cargo that if a source file changes, to rerun this build script. + project.rerun_if_sources_changed(); +} diff --git a/coins/ethereum/contracts/Schnorr.sol b/coins/ethereum/contracts/Schnorr.sol new file mode 100644 index 00000000..3f0196b2 --- /dev/null +++ b/coins/ethereum/contracts/Schnorr.sol @@ -0,0 +1,36 @@ +//SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.0; + +// see https://github.com/noot/schnorr-verify for implementation details +contract Schnorr { + // secp256k1 group order + uint256 constant public Q = + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + // parity := public key y-coord parity (27 or 28) + // px := public key x-coord + // message := 32-byte message + // s := schnorr signature + // e := schnorr signature challenge + function verify( + uint8 parity, + bytes32 px, + bytes32 message, + bytes32 s, + bytes32 e + ) public view returns (bool) { + // ecrecover = (m, v, r, s); + bytes32 sp = bytes32(Q - mulmod(uint256(s), uint256(px), Q)); + bytes32 ep = bytes32(Q - mulmod(uint256(e), uint256(px), Q)); + + require(sp != 0); + // the ecrecover precompile implementation checks that the `r` and `s` + // inputs are non-zero (in this case, `px` and `ep`), thus we don't need to + // check if they're zero.will make me + address R = ecrecover(sp, parity, px, ep); + require(R != address(0), "ecrecover failed"); + return e == keccak256( + abi.encodePacked(R, uint8(parity), px, block.chainid, message) + ); + } +} diff --git a/coins/ethereum/src/contract.rs b/coins/ethereum/src/contract.rs new file mode 100644 index 00000000..df0d295f --- /dev/null +++ b/coins/ethereum/src/contract.rs @@ -0,0 +1,52 @@ +use crate::crypto::ProcessedSignature; +use ethers::{contract::ContractFactory, prelude::*, solc::artifacts::contract::ContractBytecode}; +use eyre::{eyre, Result}; +use std::fs::File; +use std::sync::Arc; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum EthereumError { + #[error("failed to verify Schnorr signature")] + VerificationError, +} + +abigen!( + Schnorr, + "./artifacts/Schnorr.sol/Schnorr.json", + event_derives(serde::Deserialize, serde::Serialize), +); + +pub async fn deploy_schnorr_verifier_contract( + client: Arc, LocalWallet>>, +) -> Result, LocalWallet>>> { + let path = "./artifacts/Schnorr.sol/Schnorr.json"; + let artifact: ContractBytecode = serde_json::from_reader(File::open(path).unwrap()).unwrap(); + let abi = artifact.abi.unwrap(); + let bin = artifact.bytecode.unwrap().object; + let factory = ContractFactory::new(abi, bin.into_bytes().unwrap(), client.clone()); + let contract = factory.deploy(())?.send().await?; + let contract = Schnorr::new(contract.address(), client); + Ok(contract) +} + +pub async fn call_verify( + contract: &schnorr_mod::Schnorr, LocalWallet>>, + params: &ProcessedSignature, +) -> Result<()> { + let ok = contract + .verify( + params.parity + 27, + params.px.to_bytes().into(), + params.message.into(), + params.s.to_bytes().into(), + params.e.to_bytes().into(), + ) + .call() + .await?; + if ok { + Ok(()) + } else { + Err(eyre!(EthereumError::VerificationError)) + } +} diff --git a/coins/ethereum/src/crypto.rs b/coins/ethereum/src/crypto.rs new file mode 100644 index 00000000..2861ace1 --- /dev/null +++ b/coins/ethereum/src/crypto.rs @@ -0,0 +1,104 @@ +use sha3::{Digest, Keccak256}; + +use group::Group; +use k256::{ + elliptic_curve::{bigint::ArrayEncoding, ops::Reduce, sec1::ToEncodedPoint, DecompressPoint}, + AffinePoint, ProjectivePoint, Scalar, U256, +}; + +use frost::{algorithm::Hram, curve::Secp256k1}; + +pub fn keccak256(data: &[u8]) -> [u8; 32] { + Keccak256::digest(data).try_into().unwrap() +} + +pub fn hash_to_scalar(data: &[u8]) -> Scalar { + Scalar::from_uint_reduced(U256::from_be_slice(&keccak256(data))) +} + +pub fn address(point: &ProjectivePoint) -> [u8; 20] { + let encoded_point = point.to_encoded_point(false); + keccak256(&encoded_point.as_ref()[1 .. 65])[12 .. 32].try_into().unwrap() +} + +pub fn ecrecover(message: Scalar, v: u8, r: Scalar, s: Scalar) -> Option<[u8; 20]> { + if r.is_zero().into() || s.is_zero().into() { + return None; + } + + #[allow(non_snake_case)] + let R = AffinePoint::decompress(&r.to_bytes(), v.into()); + #[allow(non_snake_case)] + if let Some(R) = Option::::from(R) { + #[allow(non_snake_case)] + let R = ProjectivePoint::from(R); + + let r = r.invert().unwrap(); + let u1 = ProjectivePoint::GENERATOR * (-message * r); + let u2 = R * (s * r); + let key: ProjectivePoint = u1 + u2; + if !bool::from(key.is_identity()) { + return Some(address(&key)); + } + } + return None; +} + +#[derive(Clone, Default)] +pub struct EthereumHram {} +impl Hram for EthereumHram { + #[allow(non_snake_case)] + fn hram(R: &ProjectivePoint, A: &ProjectivePoint, m: &[u8]) -> Scalar { + let a_encoded_point = A.to_encoded_point(true); + let mut a_encoded = a_encoded_point.as_ref().to_owned(); + a_encoded[0] += 25; // Ethereum uses 27/28 for point parity + let mut data = address(R).to_vec(); + data.append(&mut a_encoded); + data.append(&mut m.to_vec()); + Scalar::from_uint_reduced(U256::from_be_slice(&keccak256(&data))) + } +} + +pub struct ProcessedSignature { + pub s: Scalar, + pub px: Scalar, + pub parity: u8, + pub message: [u8; 32], + pub e: Scalar, +} + +#[allow(non_snake_case)] +pub fn preprocess_signature_for_ecrecover( + m: [u8; 32], + R: &ProjectivePoint, + s: Scalar, + A: &ProjectivePoint, + chain_id: U256, +) -> (Scalar, Scalar) { + let processed_sig = process_signature_for_contract(m, R, s, A, chain_id); + let sr = processed_sig.s.mul(&processed_sig.px).negate(); + let er = processed_sig.e.mul(&processed_sig.px).negate(); + (sr, er) +} + +#[allow(non_snake_case)] +pub fn process_signature_for_contract( + m: [u8; 32], + R: &ProjectivePoint, + s: Scalar, + A: &ProjectivePoint, + chain_id: U256, +) -> ProcessedSignature { + let encoded_pk = A.to_encoded_point(true); + let px = &encoded_pk.as_ref()[1 .. 33]; + let px_scalar = Scalar::from_uint_reduced(U256::from_be_slice(px)); + let e = EthereumHram::hram(R, A, &[chain_id.to_be_byte_array().as_slice(), &m].concat()); + ProcessedSignature { + s, + px: px_scalar, + parity: &encoded_pk.as_ref()[0] - 2, + #[allow(non_snake_case)] + message: m, + e, + } +} diff --git a/coins/ethereum/src/lib.rs b/coins/ethereum/src/lib.rs new file mode 100644 index 00000000..75a58525 --- /dev/null +++ b/coins/ethereum/src/lib.rs @@ -0,0 +1,2 @@ +pub mod contract; +pub mod crypto; diff --git a/coins/ethereum/tests/contract.rs b/coins/ethereum/tests/contract.rs new file mode 100644 index 00000000..65d1d4a0 --- /dev/null +++ b/coins/ethereum/tests/contract.rs @@ -0,0 +1,60 @@ +use ethereum_serai::contract::{call_verify, deploy_schnorr_verifier_contract}; +use ethers::{prelude::*, utils::Anvil}; +use std::{convert::TryFrom, sync::Arc, time::Duration}; + +#[tokio::test] +async fn test_deploy_contract() { + let anvil = Anvil::new().spawn(); + let wallet: LocalWallet = anvil.keys()[0].clone().into(); + let provider = + Provider::::try_from(anvil.endpoint()).unwrap().interval(Duration::from_millis(10u64)); + let client = Arc::new(SignerMiddleware::new(provider, wallet)); + + let _contract = deploy_schnorr_verifier_contract(client).await.unwrap(); +} + +#[tokio::test] +async fn test_ecrecover_hack() { + use ethereum_serai::crypto; + use ethers::utils::keccak256; + use frost::{ + algorithm::Schnorr, + curve::Secp256k1, + tests::{algorithm_machines, key_gen, sign}, + }; + use k256::elliptic_curve::bigint::ArrayEncoding; + use k256::{Scalar, U256}; + use rand::rngs::OsRng; + + let anvil = Anvil::new().spawn(); + let wallet: LocalWallet = anvil.keys()[0].clone().into(); + let provider = + Provider::::try_from(anvil.endpoint()).unwrap().interval(Duration::from_millis(10u64)); + let chain_id = provider.get_chainid().await.unwrap(); + let client = Arc::new(SignerMiddleware::new(provider, wallet)); + + let keys = key_gen::<_, Secp256k1>(&mut OsRng); + let group_key = keys[&1].group_key(); + + const MESSAGE: &'static [u8] = b"Hello, World!"; + let hashed_message = keccak256(MESSAGE); + let chain_id = U256::from(Scalar::from(chain_id.as_u32())); + + let full_message = &[chain_id.to_be_byte_array().as_slice(), &hashed_message].concat(); + + let sig = sign( + &mut OsRng, + algorithm_machines(&mut OsRng, Schnorr::::new(), &keys), + full_message, + ); + let mut processed_sig = + crypto::process_signature_for_contract(hashed_message, &sig.R, sig.s, &group_key, chain_id); + + let contract = deploy_schnorr_verifier_contract(client).await.unwrap(); + call_verify(&contract, &processed_sig).await.unwrap(); + + // test invalid signature fails + processed_sig.message[0] = 0; + let res = call_verify(&contract, &processed_sig).await; + assert!(res.is_err()); +} diff --git a/coins/ethereum/tests/crypto.rs b/coins/ethereum/tests/crypto.rs new file mode 100644 index 00000000..f41344be --- /dev/null +++ b/coins/ethereum/tests/crypto.rs @@ -0,0 +1,80 @@ +use ethereum_serai::crypto::*; +use frost::curve::Secp256k1; +use k256::{ + elliptic_curve::{bigint::ArrayEncoding, ops::Reduce, sec1::ToEncodedPoint}, + ProjectivePoint, Scalar, U256, +}; + +#[test] +fn test_ecrecover() { + use k256::ecdsa::{ + recoverable::Signature, + signature::{Signer, Verifier}, + SigningKey, VerifyingKey, + }; + use rand::rngs::OsRng; + + let private = SigningKey::random(&mut OsRng); + let public = VerifyingKey::from(&private); + + const MESSAGE: &'static [u8] = b"Hello, World!"; + let sig: Signature = private.sign(MESSAGE); + public.verify(MESSAGE, &sig).unwrap(); + + assert_eq!( + ecrecover(hash_to_scalar(MESSAGE), sig.as_ref()[64], *sig.r(), *sig.s()).unwrap(), + address(&ProjectivePoint::from(public)) + ); +} + +#[test] +fn test_signing() { + use frost::{ + algorithm::Schnorr, + tests::{algorithm_machines, key_gen, sign}, + }; + use rand::rngs::OsRng; + + let keys = key_gen::<_, Secp256k1>(&mut OsRng); + let _group_key = keys[&1].group_key(); + + const MESSAGE: &'static [u8] = b"Hello, World!"; + + let _sig = sign( + &mut OsRng, + algorithm_machines(&mut OsRng, Schnorr::::new(), &keys), + MESSAGE, + ); +} + +#[test] +fn test_ecrecover_hack() { + use frost::{ + algorithm::Schnorr, + tests::{algorithm_machines, key_gen, sign}, + }; + use rand::rngs::OsRng; + + let keys = key_gen::<_, Secp256k1>(&mut OsRng); + let group_key = keys[&1].group_key(); + let group_key_encoded = group_key.to_encoded_point(true); + let group_key_compressed = group_key_encoded.as_ref(); + let group_key_x = Scalar::from_uint_reduced(U256::from_be_slice(&group_key_compressed[1 .. 33])); + + const MESSAGE: &'static [u8] = b"Hello, World!"; + let hashed_message = keccak256(MESSAGE); + let chain_id = U256::from(Scalar::ONE); + + let full_message = &[chain_id.to_be_byte_array().as_slice(), &hashed_message].concat(); + + let sig = sign( + &mut OsRng, + algorithm_machines(&mut OsRng, Schnorr::::new(), &keys), + full_message, + ); + + let (sr, er) = + preprocess_signature_for_ecrecover(hashed_message, &sig.R, sig.s, &group_key, chain_id); + let q = ecrecover(sr, group_key_compressed[0] - 2, group_key_x, er).unwrap(); + assert_eq!(q, address(&sig.R)); +} diff --git a/coins/ethereum/tests/mod.rs b/coins/ethereum/tests/mod.rs new file mode 100644 index 00000000..257fb61f --- /dev/null +++ b/coins/ethereum/tests/mod.rs @@ -0,0 +1,2 @@ +mod contract; +mod crypto; diff --git a/processor/Cargo.toml b/processor/Cargo.toml index df4dbcb7..7d7e3dc0 100644 --- a/processor/Cargo.toml +++ b/processor/Cargo.toml @@ -18,14 +18,16 @@ hex = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -curve25519-dalek = { version = "3", features = ["std"] } +sha3 = "0.10" blake2 = "0.10" group = "0.12" +k256 = { version = "0.11", features = ["arithmetic", "keccak256", "ecdsa"] } +curve25519-dalek = { version = "3", features = ["std"] } transcript = { package = "flexible-transcript", path = "../crypto/transcript", features = ["recommended"] } dalek-ff-group = { path = "../crypto/dalek-ff-group" } -frost = { package = "modular-frost", path = "../crypto/frost" } +frost = { package = "modular-frost", path = "../crypto/frost", features = ["secp256k1", "ed25519"] } monero = { version = "0.16", features = ["experimental"] } monero-serai = { path = "../coins/monero", features = ["multisig"] }