From b394b366f0882019253f048ccb537d1522e2d392 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 25 Mar 2024 16:13:27 -0400 Subject: [PATCH] feat(signer): ethereum implementation --- Cargo.lock | 115 +++++++++++++- Cargo.toml | 1 + signer/Cargo.toml | 6 +- signer/src/ecdsa.rs | 21 +-- signer/src/eth.rs | 269 ++++++++++++++++++++++++++++++++ signer/src/lib.rs | 5 + signer/wasm-tests/tests/wasm.rs | 26 ++- 7 files changed, 425 insertions(+), 18 deletions(-) create mode 100644 signer/src/eth.rs diff --git a/Cargo.lock b/Cargo.lock index f374c7f25ab..b96d7124f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,9 +165,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" [[package]] name = "ark-bls12-377" @@ -582,6 +582,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -2454,6 +2469,16 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "keccak-hash" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b286e6b663fb926e1eeb68528e69cb70ed46c6d65871a21b2215ae8154c6d3c" +dependencies = [ + "primitive-types", + "tiny-keccak", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -2733,6 +2758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3192,6 +3218,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.4.2", + "lazy_static", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax 0.8.2", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "psm" version = "0.1.21" @@ -3201,6 +3247,12 @@ dependencies = [ "cc", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.35" @@ -3252,6 +3304,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rayon" version = "1.8.1" @@ -3542,6 +3603,18 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ruzstd" version = "0.5.0" @@ -4763,8 +4836,10 @@ dependencies = [ "getrandom", "hex", "hmac 0.12.1", + "keccak-hash", "parity-scale-codec", "pbkdf2", + "proptest", "regex", "schnorrkel", "secp256k1", @@ -4811,6 +4886,18 @@ version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69758bda2e78f098e4ccb393021a0963bb3442eac05f135c30f61b7370bbafae" +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix 0.38.31", + "windows-sys 0.52.0", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -4872,6 +4959,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -5275,6 +5371,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -5414,6 +5516,15 @@ dependencies = [ "glob 0.2.11", ] +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.4.0" diff --git a/Cargo.toml b/Cargo.toml index c61513dc6f5..c0e8c00cf15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,6 +150,7 @@ hmac = { version = "0.12.1", default-features = false } pbkdf2 = { version = "0.12.2", default-features = false } schnorrkel = { version = "0.11.4", default-features = false } secp256k1 = { version = "0.28.2", default-features = false } +keccak-hash = { version = "0.10.0", default-features = false } secrecy = "0.8.0" sha2 = { version = "0.10.8", default-features = false } zeroize = { version = "1", default-features = false } diff --git a/signer/Cargo.toml b/signer/Cargo.toml index 7e307b22844..4b282310fa2 100644 --- a/signer/Cargo.toml +++ b/signer/Cargo.toml @@ -15,7 +15,7 @@ description = "Sign extrinsics to be submitted by Subxt" keywords = ["parity", "subxt", "extrinsic", "signer"] [features] -default = ["sr25519", "ecdsa", "subxt", "std", "native"] +default = ["sr25519", "ecdsa", "eth", "subxt", "std", "native"] std = ["regex/std", "sp-crypto-hashing/std", "pbkdf2/std", "sha2/std", "hmac/std", "bip39/std", "schnorrkel/std", "secp256k1/std", "sp-core/std"] # Pick the signer implementation(s) you need by enabling the @@ -24,6 +24,7 @@ std = ["regex/std", "sp-crypto-hashing/std", "pbkdf2/std", "sha2/std", "hmac/std # https://github.com/rust-bitcoin/rust-bitcoin/issues/930#issuecomment-1215538699 sr25519 = ["schnorrkel"] ecdsa = ["secp256k1"] +eth = ["keccak-hash", "secp256k1"] # Make the keypair algorithms here compatible with Subxt's Signer trait, # so that they can be used to sign transactions for compatible chains. @@ -50,7 +51,7 @@ bip39 = { workspace = true } schnorrkel = { workspace = true, optional = true } secp256k1 = { workspace = true, optional = true, features = ["alloc", "recovery"] } secrecy = { workspace = true } - +keccak-hash = { workspace = true, optional = true } # We only pull this in to enable the JS flag for schnorrkel to use. getrandom = { workspace = true, optional = true } @@ -58,6 +59,7 @@ getrandom = { workspace = true, optional = true } [dev-dependencies] sp-core = { workspace = true } sp-keyring = { workspace = true } +proptest = "1.4.0" [package.metadata.cargo-machete] ignored = ["getrandom"] diff --git a/signer/src/ecdsa.rs b/signer/src/ecdsa.rs index de84d4bd9a2..12b76a12ac7 100644 --- a/signer/src/ecdsa.rs +++ b/signer/src/ecdsa.rs @@ -160,21 +160,22 @@ impl Keypair { /// Sign some message. These bytes can be used directly in a Substrate `MultiSignature::Ecdsa(..)`. pub fn sign(&self, message: &[u8]) -> Signature { - // From sp_core::ecdsa::sign: let message_hash = sp_crypto_hashing::blake2_256(message); - // From sp_core::ecdsa::sign_prehashed: let wrapped = Message::from_digest_slice(&message_hash).expect("Message is 32 bytes; qed"); - let recsig: RecoverableSignature = - Secp256k1::signing_only().sign_ecdsa_recoverable(&wrapped, &self.0.secret_key()); - // From sp_core::ecdsa's `impl From for Signature`: - let (recid, sig): (_, [u8; 64]) = recsig.serialize_compact(); - let mut signature_bytes: [u8; 65] = [0; 65]; - signature_bytes[..64].copy_from_slice(&sig); - signature_bytes[64] = (recid.to_i32() & 0xFF) as u8; - Signature(signature_bytes) + Signature(sign(&self.0.secret_key(), &wrapped)) } } +pub(crate) fn sign(secret_key: &secp256k1::SecretKey, message: &Message) -> [u8; 65] { + let recsig: RecoverableSignature = + Secp256k1::signing_only().sign_ecdsa_recoverable(message, secret_key); + let (recid, sig): (_, [u8; 64]) = recsig.serialize_compact(); + let mut signature_bytes: [u8; 65] = [0; 65]; + signature_bytes[..64].copy_from_slice(&sig); + signature_bytes[64] = (recid.to_i32() & 0xFF) as u8; + signature_bytes +} + /// Verify that some signature for a message was created by the owner of the [`PublicKey`]. /// /// ```rust diff --git a/signer/src/eth.rs b/signer/src/eth.rs new file mode 100644 index 00000000000..4c48b8eb880 --- /dev/null +++ b/signer/src/eth.rs @@ -0,0 +1,269 @@ +// Copyright 2019-2023 Parity Technologies (UK) Ltd. +// This file is dual-licensed as Apache-2.0 or GPL-3.0. +// see LICENSE for license details. + +//! An ethereum signer implementation. +use derive_more::{Display, From}; +use hex::FromHex; +use keccak_hash::keccak; +use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; + +/// An ethereum signer implementation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EthereumSigner(Keypair); + +impl From for EthereumSigner { + fn from(kp: crate::ecdsa::Keypair) -> Self { + EthereumSigner(kp.0) + } +} + +impl EthereumSigner { + /// Construct an ethereum signer from a hex-encoded private key. + pub fn from_private_key_hex(hex: &str) -> Result { + let seed = <[u8; 32]>::from_hex(hex).map_err(Error::Hex)?; + let secret = SecretKey::from_slice(&seed).map_err(|_| Error::InvalidPrivateKey)?; + Ok(EthereumSigner(secp256k1::Keypair::from_secret_key( + &Secp256k1::signing_only(), + &secret, + ))) + } + + /// Obtain the [`secp256k1::PublicKey`] of this signer. + pub fn public_key(&self) -> secp256k1::PublicKey { + self.0.public_key() + } + + /// Obtains the public address of the account by taking the last 20 bytes + /// of the Keccak-256 hash of the public key. + pub fn account_id(&self) -> AccountId20 { + let uncompressed = self.0.public_key().serialize_uncompressed(); + let hash = keccak(&uncompressed[1..]).0; + let hash20 = hash[12..].try_into().expect("should be 20 bytes"); + AccountId20(hash20) + } + + /// Sign any arbitrary message. + pub fn sign(&self, signer_payload: &[u8]) -> EthereumSignature { + let message_hash = keccak(signer_payload); + let wrapped = + Message::from_digest_slice(message_hash.as_bytes()).expect("Message is 32 bytes; qed"); + EthereumSignature(crate::ecdsa::sign(&self.0.secret_key(), &wrapped)) + } +} + +/// A signature generated by [`EthereumSigner::sign()`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, codec::Encode)] +pub struct EthereumSignature(pub [u8; 65]); + +impl AsRef<[u8; 65]> for EthereumSignature { + fn as_ref(&self) -> &[u8; 65] { + &self.0 + } +} + +/// A 20-byte cryptographic identifier. +#[derive(Debug, Copy, Clone, PartialEq, Eq, codec::Encode)] +pub struct AccountId20(pub [u8; 20]); + +impl AsRef<[u8]> for AccountId20 { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// Verify that some signature for a message was created by the owner of the [`secp256k1::PublicKey`]. +/// +/// ```rust +/// use subxt_signer::eth; +/// +/// let signer = eth::dev::alice(); +/// let message = b"Hello!"; +/// +/// let signature = signer.sign(message); +/// let public_key = signer.public_key(); +/// assert!(eth::verify(&signature, message, &public_key)); +/// ``` +pub fn verify>( + sig: &EthereumSignature, + message: M, + pub_key: &secp256k1::PublicKey, +) -> bool { + let Ok(signature) = secp256k1::ecdsa::Signature::from_compact(&sig.0[..64]) else { + return false; + }; + let message_hash = keccak(message.as_ref()); + let wrapped = + Message::from_digest_slice(message_hash.as_bytes()).expect("Message is 32 bytes; qed"); + + Secp256k1::verification_only() + .verify_ecdsa(&wrapped, &signature, pub_key) + .is_ok() +} + +/// An error handed back if creating the ethereum signer fails. +#[derive(Debug, PartialEq, Display, From)] +pub enum Error { + /// Invalid private key. + #[display(fmt = "Invalid private key")] + #[from(ignore)] + InvalidPrivateKey, + /// Invalid hex. + #[display(fmt = "Cannot parse hex string: {_0}")] + Hex(hex::FromHexError), +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +/// Dev accounts, helpful for testing but not to be used in production, +/// since the secret keys are known. +pub mod dev { + use super::*; + use crate::ecdsa::dev; + + once_static_cloned! { + /// Equivalent to `{DEV_PHRASE}//Alice`. + pub fn alice() -> EthereumSigner { + dev::alice().into() + } + /// Equivalent to `{DEV_PHRASE}//Bob`. + pub fn bob() -> EthereumSigner { + dev::bob().into() + } + /// Equivalent to `{DEV_PHRASE}//Charlie`. + pub fn charlie() -> EthereumSigner { + dev::charlie().into() + } + /// Equivalent to `{DEV_PHRASE}//Dave`. + pub fn dave() -> EthereumSigner { + dev::dave().into() + } + /// Equivalent to `{DEV_PHRASE}//Eve`. + pub fn eve() -> EthereumSigner { + dev::eve().into() + } + /// Equivalent to `{DEV_PHRASE}//Ferdie`. + pub fn ferdie() -> EthereumSigner { + dev::ferdie().into() + } + /// Equivalent to `{DEV_PHRASE}//One`. + pub fn one() -> EthereumSigner { + dev::one().into() + } + /// Equivalent to `{DEV_PHRASE}//Two`. + pub fn two() -> EthereumSigner { + dev::two().into() + } + } +} + +#[cfg(feature = "subxt")] +mod subxt_compat { + use super::*; + + impl subxt::tx::Signer for EthereumSigner + where + T::AccountId: From, + T::Address: From, + T::Signature: From, + { + fn account_id(&self) -> T::AccountId { + self.account_id().into() + } + + fn address(&self) -> T::Address { + self.account_id().into() + } + + fn sign(&self, signer_payload: &[u8]) -> T::Signature { + self.sign(signer_payload).into() + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use proptest::prelude::*; + + use hex::ToHex; + + enum StubEthRuntimeConfig {} + + impl subxt::Config for StubEthRuntimeConfig { + type Hash = subxt::utils::H256; + type AccountId = super::AccountId20; + type Address = super::AccountId20; + type Signature = super::EthereumSignature; + type Hasher = subxt::config::substrate::BlakeTwo256; + type Header = + subxt::config::substrate::SubstrateHeader; + type ExtrinsicParams = subxt::config::SubstrateExtrinsicParams; + type AssetId = u32; + } + + type Signer = dyn subxt::tx::Signer; + + prop_compose! { + fn keypair()(seed in any::<[u8; 32]>()) -> secp256k1::Keypair { + let secret = SecretKey::from_slice(&seed).expect("valid secret key"); + secp256k1::Keypair::from_secret_key( + &Secp256k1::new(), + &secret, + ) + } + } + + proptest! { + #[test] + fn check_subxt_signer_implementation_matches(keypair in keypair(), msg in ".*") { + let eth_signer = EthereumSigner(keypair); + let msg_as_bytes = msg.as_bytes(); + + assert_eq!(Signer::account_id(ð_signer), eth_signer.account_id()); + assert_eq!(Signer::sign(ð_signer, &msg_as_bytes), eth_signer.sign(msg_as_bytes)); + } + + #[test] + fn check_account_id(keypair in keypair()) { + let account_id = { + let uncompressed = keypair.public_key().serialize_uncompressed(); + let hash = keccak(&uncompressed[1..]).0; + let hash20 = hash[12..].try_into().expect("should be 20 bytes"); + AccountId20(hash20) + }; + let eth_signer = EthereumSigner(keypair); + + assert_eq!(eth_signer.account_id(), account_id); + + } + + #[test] + fn check_account_id_eq_address(keypair in keypair()) { + let eth_signer = EthereumSigner(keypair); + assert_eq!(Signer::account_id(ð_signer), Signer::address(ð_signer)); + } + + #[test] + fn check_from_private_key_hex_matches(keypair in keypair()) { + let private_key = keypair.secret_key(); + let private_key_hex = private_key.as_ref().encode_hex::(); + let eth_signer = EthereumSigner::from_private_key_hex(&private_key_hex) + .expect("valid private key"); + assert_eq!(eth_signer, EthereumSigner(keypair)); + } + + #[test] + fn check_signing_and_verifying_matches(keypair in keypair(), msg in ".*") { + let eth_signer = EthereumSigner(keypair); + let sig = Signer::sign(ð_signer, msg.as_bytes()); + + assert!(verify( + &sig, + msg, + ð_signer.public_key()) + ); + } + } +} diff --git a/signer/src/lib.rs b/signer/src/lib.rs index db13732327c..18abb9eb33f 100644 --- a/signer/src/lib.rs +++ b/signer/src/lib.rs @@ -32,6 +32,11 @@ pub mod sr25519; #[cfg_attr(docsrs, doc(cfg(feature = "ecdsa")))] pub mod ecdsa; +// An ethereum signer implementation. +#[cfg(feature = "eth")] +#[cfg_attr(docsrs, doc(cfg(feature = "eth")))] +pub mod eth; + // Re-export useful bits and pieces for generating a Pair from a phrase, // namely the Mnemonic struct. pub use bip39; diff --git a/signer/wasm-tests/tests/wasm.rs b/signer/wasm-tests/tests/wasm.rs index d5cf4227ba1..3121f04af4c 100644 --- a/signer/wasm-tests/tests/wasm.rs +++ b/signer/wasm-tests/tests/wasm.rs @@ -1,6 +1,6 @@ #![cfg(target_arch = "wasm32")] -use subxt_signer::{ ecdsa, sr25519 }; +use subxt_signer::{ecdsa, eth, sr25519}; use wasm_bindgen_test::*; wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); @@ -22,7 +22,11 @@ async fn wasm_sr25519_signing_works() { // There's some non-determinism in the signing, so this ensures that // the rand stuff is configured properly to run ok in wasm. let signature = alice.sign(b"Hello there"); - assert!(sr25519::verify(&signature, b"Hello there", &alice.public_key())); + assert!(sr25519::verify( + &signature, + b"Hello there", + &alice.public_key() + )); } #[wasm_bindgen_test] @@ -32,5 +36,19 @@ async fn wasm_ecdsa_signing_works() { // There's some non-determinism in the signing, so this ensures that // the rand stuff is configured properly to run ok in wasm. let signature = alice.sign(b"Hello there"); - assert!(ecdsa::verify(&signature, b"Hello there", &alice.public_key())); -} \ No newline at end of file + assert!(ecdsa::verify( + &signature, + b"Hello there", + &alice.public_key() + )); +} + +#[wasm_bindgen_test] +async fn wasm_eth_signing_works() { + let alice = ecdsa::eth::alice(); + + // There's some non-determinism in the signing, so this ensures that + // the rand stuff is configured properly to run ok in wasm. + let signature = alice.sign(b"Hello there"); + assert!(eth::verify(&signature, b"Hello there", &alice.public_key())); +}