Dedicated crate for the Schnorr contract

This commit is contained in:
Luke Parker
2024-09-15 00:41:16 -04:00
parent bdf89f5350
commit 1c5bc2259e
20 changed files with 389 additions and 222 deletions

View File

@@ -34,7 +34,7 @@ pub fn build(contracts_path: &str, artifacts_path: &str) -> Result<(), String> {
let args = [
"--base-path", ".",
"-o", "./artifacts", "--overwrite",
"--bin", "--abi",
"--bin", "--bin-runtime", "--abi",
"--via-ir", "--optimize",
"--no-color",
];

1
networks/ethereum/schnorr/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
artifacts

View File

@@ -0,0 +1,42 @@
[package]
name = "ethereum-schnorr-contract"
version = "0.1.0"
description = "A Solidity contract to verify Schnorr signatures"
license = "AGPL-3.0-only"
repository = "https://github.com/serai-dex/serai/tree/develop/networks/ethereum/schnorr"
authors = ["Luke Parker <lukeparker5132@gmail.com>", "Elizabeth Binks <elizabethjbinks@gmail.com>"]
edition = "2021"
publish = false
rust-version = "1.79"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[dependencies]
subtle = { version = "2", default-features = false, features = ["std"] }
sha3 = { version = "0.10", default-features = false, features = ["std"] }
group = { version = "0.13", default-features = false, features = ["alloc"] }
k256 = { version = "^0.13.1", default-features = false, features = ["std", "arithmetic"] }
alloy-sol-types = { version = "0.8", default-features = false }
[build-dependencies]
build-solidity-contracts = { path = "../build-contracts", version = "0.1" }
[dev-dependencies]
rand_core = { version = "0.6", default-features = false, features = ["std"] }
alloy-core = { version = "0.8", default-features = false }
alloy-simple-request-transport = { path = "../../../networks/ethereum/alloy-simple-request-transport", default-features = false }
alloy-rpc-types-eth = { version = "0.3", default-features = false }
alloy-rpc-client = { version = "0.3", default-features = false }
alloy-provider = { version = "0.3", default-features = false }
alloy-node-bindings = { version = "0.3", default-features = false }
tokio = { version = "1", default-features = false, features = ["macros"] }

View File

@@ -0,0 +1,15 @@
AGPL-3.0-only license
Copyright (c) 2022-2024 Luke Parker
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License Version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,5 @@
# Ethereum Schnorr Contract
An Ethereum contract to verify Schnorr signatures.
This crate will fail to build if `solc` is not installed and available.

View File

@@ -0,0 +1,3 @@
fn main() {
build_solidity_contracts::build("contracts", "artifacts").unwrap();
}

View File

@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.0;
// See https://github.com/noot/schnorr-verify for implementation details
library Schnorr {
// secp256k1 group order
uint256 constant private Q =
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
// We fix the key to have an even y coordinate to save a word when verifying
// signatures. This is comparable to Bitcoin Taproot's encoding of keys
uint8 constant private KEY_PARITY = 27;
// px := public key x-coordinate, where the public key has an even y-coordinate
// message := the message signed
// c := Schnorr signature challenge
// s := Schnorr signature solution
function verify(
bytes32 px,
bytes memory message,
bytes32 c,
bytes32 s
) internal pure returns (bool) {
// ecrecover = (m, v, r, s) -> key
// We instead pass the following to obtain the nonce (not the key)
// Then we hash it and verify it matches the challenge
bytes32 sa = bytes32(Q - mulmod(uint256(s), uint256(px), Q));
bytes32 ca = bytes32(Q - mulmod(uint256(c), uint256(px), Q));
// For safety, we want each input to ecrecover to not be 0 (sa, px, ca)
// The ecrecover precompile checks `r` and `s` (`px` and `ca`) are non-zero
// That leaves us to check `sa` are non-zero
if (sa == 0) return false;
address R = ecrecover(sa, KEY_PARITY, px, ca);
if (R == address(0)) return false;
// Check the signature is correct by rebuilding the challenge
return c == keccak256(abi.encodePacked(R, px, message));
}
}

View File

@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.0;
import "../Schnorr.sol";
contract TestSchnorr {
function verify(
bytes32 public_key,
bytes calldata message,
bytes32 c,
bytes32 s
) external pure returns (bool) {
return Schnorr.verify(public_key, message, c, s);
}
}

View File

@@ -0,0 +1,15 @@
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![allow(non_snake_case)]
/// The initialization bytecode of the Schnorr library.
pub const INIT_BYTECODE: &str = include_str!("../artifacts/Schnorr.bin");
mod public_key;
pub use public_key::PublicKey;
mod signature;
pub use signature::Signature;
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,68 @@
use subtle::Choice;
use group::ff::PrimeField;
use k256::{
elliptic_curve::{
ops::Reduce,
point::{AffineCoordinates, DecompressPoint},
},
AffinePoint, ProjectivePoint, Scalar, U256 as KU256,
};
/// A public key for the Schnorr Solidity library.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct PublicKey {
A: ProjectivePoint,
x_coordinate: [u8; 32],
}
impl PublicKey {
/// Construct a new `PublicKey`.
///
/// This will return None if the provided point isn't eligible to be a public key (due to
/// bounds such as parity).
#[must_use]
pub fn new(A: ProjectivePoint) -> Option<PublicKey> {
let affine = A.to_affine();
// Only allow even keys to save a word within Ethereum
if bool::from(affine.y_is_odd()) {
None?;
}
let x_coordinate = affine.x();
// Return None if the x-coordinate isn't mutual to both fields
// While reductions shouldn't be an issue, it's one less headache/concern to have
// The trivial amount of public keys this makes non-representable aren't a concern
if <Scalar as Reduce<KU256>>::reduce_bytes(&x_coordinate).to_repr() != x_coordinate {
None?;
}
Some(PublicKey { A, x_coordinate: x_coordinate.into() })
}
/// The point for this public key.
#[must_use]
pub fn point(&self) -> ProjectivePoint {
self.A
}
/// The Ethereum representation of this public key.
#[must_use]
pub fn eth_repr(&self) -> [u8; 32] {
// We only encode the x-coordinate due to fixing the sign of the y-coordinate
self.x_coordinate
}
/// Construct a PublicKey from its Ethereum representation.
// This wouldn't be possible if the x-coordinate had been reduced
#[must_use]
pub fn from_eth_repr(repr: [u8; 32]) -> Option<Self> {
let x_coordinate = repr;
let y_is_odd = Choice::from(0);
let A_affine =
Option::<AffinePoint>::from(AffinePoint::decompress(&x_coordinate.into(), y_is_odd))?;
let A = ProjectivePoint::from(A_affine);
Some(PublicKey { A, x_coordinate })
}
}

View File

@@ -0,0 +1,95 @@
use std::io;
use sha3::{Digest, Keccak256};
use group::ff::PrimeField;
use k256::{
elliptic_curve::{ops::Reduce, sec1::ToEncodedPoint},
ProjectivePoint, Scalar, U256 as KU256,
};
use crate::PublicKey;
/// A signature for the Schnorr Solidity library.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Signature {
c: Scalar,
s: Scalar,
}
impl Signature {
/// Construct a new `Signature`.
#[must_use]
pub fn new(c: Scalar, s: Scalar) -> Signature {
Signature { c, s }
}
/// The challenge for a signature.
#[must_use]
pub fn challenge(R: ProjectivePoint, key: &PublicKey, message: &[u8]) -> Scalar {
// H(R || A || m)
let mut hash = Keccak256::new();
// We transcript the nonce as an address since ecrecover yields an address
hash.update({
let uncompressed_encoded_point = R.to_encoded_point(false);
// Skip the prefix byte marking this as uncompressed
let x_and_y_coordinates = &uncompressed_encoded_point.as_ref()[1 ..];
// Last 20 bytes of the hash of the x and y coordinates
&Keccak256::digest(x_and_y_coordinates)[12 ..]
});
hash.update(key.eth_repr());
hash.update(message);
<Scalar as Reduce<KU256>>::reduce_bytes(&hash.finalize())
}
/// Verify a signature.
#[must_use]
pub fn verify(&self, key: &PublicKey, message: &[u8]) -> bool {
// Recover the nonce
let R = (ProjectivePoint::GENERATOR * self.s) - (key.point() * self.c);
// Check the challenge
Self::challenge(R, key, message) == self.c
}
/// The challenge present within this signature.
pub fn c(&self) -> Scalar {
self.c
}
/// The signature solution present within this signature.
pub fn s(&self) -> Scalar {
self.s
}
/// Convert the signature to bytes.
#[must_use]
pub fn to_bytes(&self) -> [u8; 64] {
let mut res = [0; 64];
res[.. 32].copy_from_slice(self.c.to_repr().as_ref());
res[32 ..].copy_from_slice(self.s.to_repr().as_ref());
res
}
/// Write the signature.
pub fn write(&self, writer: &mut impl io::Write) -> io::Result<()> {
writer.write_all(&self.to_bytes())
}
/// Read a signature.
pub fn read(reader: &mut impl io::Read) -> io::Result<Self> {
let mut read_F = || -> io::Result<Scalar> {
let mut bytes = [0; 32];
reader.read_exact(&mut bytes)?;
Option::<Scalar>::from(Scalar::from_repr(bytes.into()))
.ok_or_else(|| io::Error::other("invalid scalar"))
};
let c = read_F()?;
let s = read_F()?;
Ok(Signature { c, s })
}
/// Read a signature from bytes.
pub fn from_bytes(bytes: [u8; 64]) -> io::Result<Self> {
Self::read(&mut bytes.as_slice())
}
}

View File

@@ -0,0 +1,103 @@
use std::sync::Arc;
use rand_core::{RngCore, OsRng};
use group::ff::{Field, PrimeField};
use k256::{Scalar, ProjectivePoint};
use alloy_core::primitives::Address;
use alloy_sol_types::SolCall;
use alloy_simple_request_transport::SimpleRequest;
use alloy_rpc_types_eth::{TransactionInput, TransactionRequest};
use alloy_rpc_client::ClientBuilder;
use alloy_provider::{Provider, RootProvider};
use alloy_node_bindings::{Anvil, AnvilInstance};
use crate::{PublicKey, Signature};
#[allow(warnings)]
#[allow(needless_pass_by_value)]
#[allow(clippy::all)]
#[allow(clippy::ignored_unit_patterns)]
#[allow(clippy::redundant_closure_for_method_calls)]
mod abi {
alloy_sol_types::sol!("contracts/tests/Schnorr.sol");
pub(crate) use TestSchnorr::*;
}
async fn setup_test() -> (AnvilInstance, Arc<RootProvider<SimpleRequest>>, Address) {
let anvil = Anvil::new().spawn();
let provider = Arc::new(RootProvider::new(
ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true),
));
let mut address = [0; 20];
OsRng.fill_bytes(&mut address);
let address = Address::from(address);
let _: () = provider
.raw_request(
"anvil_setCode".into(),
[address.to_string(), include_str!("../artifacts/TestSchnorr.bin-runtime").to_string()],
)
.await
.unwrap();
(anvil, provider, address)
}
async fn call_verify(
provider: &RootProvider<SimpleRequest>,
address: Address,
public_key: &PublicKey,
message: &[u8],
signature: &Signature,
) -> bool {
let public_key: [u8; 32] = public_key.eth_repr();
let c_bytes: [u8; 32] = signature.c().to_repr().into();
let s_bytes: [u8; 32] = signature.s().to_repr().into();
let call = TransactionRequest::default().to(address).input(TransactionInput::new(
abi::verifyCall::new((
public_key.into(),
message.to_vec().into(),
c_bytes.into(),
s_bytes.into(),
))
.abi_encode()
.into(),
));
let bytes = provider.call(&call).await.unwrap();
let res = abi::verifyCall::abi_decode_returns(&bytes, true).unwrap();
res._0
}
#[tokio::test]
async fn test_verify() {
let (_anvil, provider, address) = setup_test().await;
for _ in 0 .. 100 {
let (key, public_key) = loop {
let key = Scalar::random(&mut OsRng);
if let Some(public_key) = PublicKey::new(ProjectivePoint::GENERATOR * key) {
break (key, public_key);
}
};
let nonce = Scalar::random(&mut OsRng);
let mut message = vec![0; 1 + usize::try_from(OsRng.next_u32() % 256).unwrap()];
OsRng.fill_bytes(&mut message);
let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &public_key, &message);
let s = nonce + (c * key);
let sig = Signature::new(c, s);
assert!(sig.verify(&public_key, &message));
assert!(call_verify(&provider, address, &public_key, &message, &sig).await);
// Mutate the message and make sure the signature now fails to verify
message[0] = message[0].wrapping_add(1);
assert!(!call_verify(&provider, address, &public_key, &message, &sig).await);
}
}