diff --git a/networks/ethereum/schnorr/Cargo.toml b/networks/ethereum/schnorr/Cargo.toml index 5c9c1596..2e9597c8 100644 --- a/networks/ethereum/schnorr/Cargo.toml +++ b/networks/ethereum/schnorr/Cargo.toml @@ -21,15 +21,16 @@ 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"] } +k256 = { version = "^0.13.1", default-features = false, features = ["ecdsa"] } + alloy-core = { version = "0.8", default-features = false } +alloy-sol-types = { 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 } diff --git a/networks/ethereum/schnorr/contracts/Schnorr.sol b/networks/ethereum/schnorr/contracts/Schnorr.sol index 1c39c6d7..b13696cf 100644 --- a/networks/ethereum/schnorr/contracts/Schnorr.sol +++ b/networks/ethereum/schnorr/contracts/Schnorr.sol @@ -7,8 +7,9 @@ library Schnorr { 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 + // We fix the key to have: + // 1) An even y-coordinate + // 2) An x-coordinate < Q uint8 constant private KEY_PARITY = 27; // px := public key x-coordinate, where the public key has an even y-coordinate @@ -27,11 +28,17 @@ library Schnorr { 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; + /* + The ecrecover precompile checks `r` and `s` (`px` and `ca`) are non-zero, + banning the two keys with zero for their x-coordinate and zero challenge. + Each has negligible probability of occuring (assuming zero x-coordinates + are even on-curve in the first place). + + `sa` is not checked to be non-zero yet it does not need to be. The inverse + of it is never taken. + */ address R = ecrecover(sa, KEY_PARITY, px, ca); + // The ecrecover failed if (R == address(0)) return false; // Check the signature is correct by rebuilding the challenge diff --git a/networks/ethereum/schnorr/src/public_key.rs b/networks/ethereum/schnorr/src/public_key.rs index b0cc04df..3c39552f 100644 --- a/networks/ethereum/schnorr/src/public_key.rs +++ b/networks/ethereum/schnorr/src/public_key.rs @@ -37,7 +37,13 @@ impl PublicKey { None?; } - Some(PublicKey { A, x_coordinate: x_coordinate.into() }) + let x_coordinate: [u8; 32] = x_coordinate.into(); + // Returns None if the x-coordinate is 0 + // Such keys will never have their signatures able to be verified + if x_coordinate == [0; 32] { + None?; + } + Some(PublicKey { A, x_coordinate }) } /// The point for this public key. diff --git a/networks/ethereum/schnorr/src/tests.rs b/networks/ethereum/schnorr/src/tests/mod.rs similarity index 99% rename from networks/ethereum/schnorr/src/tests.rs rename to networks/ethereum/schnorr/src/tests/mod.rs index 62bb8542..90774e30 100644 --- a/networks/ethereum/schnorr/src/tests.rs +++ b/networks/ethereum/schnorr/src/tests/mod.rs @@ -17,6 +17,8 @@ use alloy_node_bindings::{Anvil, AnvilInstance}; use crate::{PublicKey, Signature}; +mod premise; + #[expect(warnings)] #[expect(needless_pass_by_value)] #[expect(clippy::all)] diff --git a/networks/ethereum/schnorr/src/tests/premise.rs b/networks/ethereum/schnorr/src/tests/premise.rs new file mode 100644 index 00000000..01571a43 --- /dev/null +++ b/networks/ethereum/schnorr/src/tests/premise.rs @@ -0,0 +1,111 @@ +use rand_core::{RngCore, OsRng}; + +use sha3::{Digest, Keccak256}; +use group::ff::{Field, PrimeField}; +use k256::{ + elliptic_curve::{ops::Reduce, point::AffineCoordinates, sec1::ToEncodedPoint}, + ecdsa::{ + self, hazmat::SignPrimitive, signature::hazmat::PrehashVerifier, SigningKey, VerifyingKey, + }, + U256, Scalar, ProjectivePoint, +}; + +use alloy_core::primitives::Address; + +use crate::{PublicKey, Signature}; + +// The ecrecover opcode, yet with if the y is odd replacing v +fn ecrecover(message: Scalar, odd_y: bool, r: Scalar, s: Scalar) -> Option<[u8; 20]> { + let sig = ecdsa::Signature::from_scalars(r, s).ok()?; + let message: [u8; 32] = message.to_repr().into(); + alloy_core::primitives::Signature::from_signature_and_parity( + sig, + alloy_core::primitives::Parity::Parity(odd_y), + ) + .ok()? + .recover_address_from_prehash(&alloy_core::primitives::B256::from(message)) + .ok() + .map(Into::into) +} + +// Test ecrecover behaves as expected +#[test] +fn test_ecrecover() { + let private = SigningKey::random(&mut OsRng); + let public = VerifyingKey::from(&private); + + // Sign the signature + const MESSAGE: &[u8] = b"Hello, World!"; + let (sig, recovery_id) = private + .as_nonzero_scalar() + .try_sign_prehashed(Scalar::random(&mut OsRng), &Keccak256::digest(MESSAGE)) + .unwrap(); + + // Sanity check the signature verifies + #[allow(clippy::unit_cmp)] // Intended to assert this wasn't changed to Result + { + assert_eq!(public.verify_prehash(&Keccak256::digest(MESSAGE), &sig).unwrap(), ()); + } + + // Perform the ecrecover + assert_eq!( + ecrecover( + >::reduce_bytes(&Keccak256::digest(MESSAGE)), + u8::from(recovery_id.unwrap().is_y_odd()) == 1, + *sig.r(), + *sig.s() + ) + .unwrap(), + Address::from_raw_public_key(&public.to_encoded_point(false).as_ref()[1 ..]), + ); +} + +// Test that we can recover the nonce from a Schnorr signature via a call to ecrecover, the premise +// of efficiently verifying Schnorr signatures in an Ethereum contract +#[test] +fn nonce_recovery_via_ecrecover() { + 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 R = ProjectivePoint::GENERATOR * nonce; + + let mut message = vec![0; 1 + usize::try_from(OsRng.next_u32() % 256).unwrap()]; + OsRng.fill_bytes(&mut message); + + let c = Signature::challenge(R, &public_key, &message); + let s = nonce + (c * key); + + /* + An ECDSA signature is `(r, s)` with `s = (H(m) + rx) / k`, where: + - `m` is the message + - `r` is the x-coordinate of the nonce, reduced into a scalar + - `x` is the private key + - `k` is the nonce + + We fix the recovery ID to be for the even key with an x-coordinate < the order. Accordingly, + `kG = Point::from(Even, r)`. This enables recovering the public key via + `((s Point::from(Even, r)) - H(m)G) / r`. + + We want to calculate `R` from `(c, s)` where `s = r + cx`. That means we need to calculate + `sG - cX`. + + We can calculate `sG - cX` with `((s Point::from(Even, r)) - H(m)G) / r` if: + - Latter `r` = `X.x` + - Latter `s` = `c` + - `H(m)` = former `s` + This gets us to `(cX - sG) / X.x`. If we additionally scale the latter's `s, H(m)` values (the + former's `c, s` values) by `X.x`, we get `cX - sG`. This just requires negating each to achieve + `sG - cX`. + */ + let x_scalar = >::reduce_bytes(&public_key.point().to_affine().x()); + let sa = -(s * x_scalar); + let ca = -(c * x_scalar); + + let q = ecrecover(sa, false, x_scalar, ca).unwrap(); + assert_eq!(q, Address::from_raw_public_key(&R.to_encoded_point(false).as_ref()[1 ..])); +} diff --git a/processor/ethereum/ethereum-serai/src/tests/crypto.rs b/processor/ethereum/ethereum-serai/src/tests/crypto.rs index a668b2d6..a4f86ae9 100644 --- a/processor/ethereum/ethereum-serai/src/tests/crypto.rs +++ b/processor/ethereum/ethereum-serai/src/tests/crypto.rs @@ -16,54 +16,6 @@ use frost::{ use crate::{crypto::*, tests::key_gen}; -// The ecrecover opcode, yet with parity replacing v -pub(crate) fn ecrecover(message: Scalar, odd_y: bool, r: Scalar, s: Scalar) -> Option<[u8; 20]> { - let sig = ecdsa::Signature::from_scalars(r, s).ok()?; - let message: [u8; 32] = message.to_repr().into(); - alloy_core::primitives::Signature::from_signature_and_parity( - sig, - alloy_core::primitives::Parity::Parity(odd_y), - ) - .ok()? - .recover_address_from_prehash(&alloy_core::primitives::B256::from(message)) - .ok() - .map(Into::into) -} - -#[test] -fn test_ecrecover() { - let private = SigningKey::random(&mut OsRng); - let public = VerifyingKey::from(&private); - - // Sign the signature - const MESSAGE: &[u8] = b"Hello, World!"; - let (sig, recovery_id) = private - .as_nonzero_scalar() - .try_sign_prehashed( - ::F::random(&mut OsRng), - &keccak256(MESSAGE).into(), - ) - .unwrap(); - - // Sanity check the signature verifies - #[allow(clippy::unit_cmp)] // Intended to assert this wasn't changed to Result - { - assert_eq!(public.verify_prehash(&keccak256(MESSAGE), &sig).unwrap(), ()); - } - - // Perform the ecrecover - assert_eq!( - ecrecover( - hash_to_scalar(MESSAGE), - u8::from(recovery_id.unwrap().is_y_odd()) == 1, - *sig.r(), - *sig.s() - ) - .unwrap(), - address(&ProjectivePoint::from(public.as_affine())) - ); -} - // Run the sign test with the EthereumHram #[test] fn test_signing() { @@ -75,31 +27,3 @@ fn test_signing() { let _sig = sign(&mut OsRng, &algo, keys.clone(), algorithm_machines(&mut OsRng, &algo, &keys), MESSAGE); } - -#[allow(non_snake_case)] -pub fn preprocess_signature_for_ecrecover( - R: ProjectivePoint, - public_key: &PublicKey, - m: &[u8], - s: Scalar, -) -> (Scalar, Scalar) { - let c = EthereumHram::hram(&R, &public_key.A, m); - let sa = -(s * public_key.px); - let ca = -(c * public_key.px); - (sa, ca) -} - -#[test] -fn test_ecrecover_hack() { - let (keys, public_key) = key_gen(); - - const MESSAGE: &[u8] = b"Hello, World!"; - - let algo = IetfSchnorr::::ietf(); - let sig = - sign(&mut OsRng, &algo, keys.clone(), algorithm_machines(&mut OsRng, &algo, &keys), MESSAGE); - - let (sa, ca) = preprocess_signature_for_ecrecover(sig.R, &public_key, MESSAGE, sig.s); - let q = ecrecover(sa, false, public_key.px, ca).unwrap(); - assert_eq!(q, address(&sig.R)); -}