From 6c467e2f2cdcbaacc7bb6553b20147018332487c Mon Sep 17 00:00:00 2001 From: Akosh Farkash Date: Thu, 22 Jun 2023 11:24:15 +0100 Subject: [PATCH] FM-114: Ethereum signature check (#127) * FM-114: Move fvm<->evm conversion to the message crate * FM-114: fvm <-> eth tx mapping * FM-114: Check ethereum signature * FM-114: Check the fields which aren't part of the signature too --- Cargo.lock | 4 + fendermint/app/Cargo.toml | 2 +- fendermint/eth/api/examples/ethers.rs | 2 +- fendermint/eth/api/src/conv/from_eth.rs | 87 +------- fendermint/eth/api/src/conv/from_fvm.rs | 146 +------------ fendermint/eth/api/src/conv/from_tm.rs | 10 +- fendermint/rpc/Cargo.toml | 2 +- fendermint/rpc/src/message.rs | 3 + fendermint/testing/src/arb/mod.rs | 16 ++ fendermint/vm/actor_interface/src/eam.rs | 7 +- fendermint/vm/interpreter/src/signed.rs | 2 + fendermint/vm/message/Cargo.toml | 12 +- fendermint/vm/message/src/conv/from_eth.rs | 91 ++++++++ fendermint/vm/message/src/conv/from_fvm.rs | 241 +++++++++++++++++++++ fendermint/vm/message/src/conv/mod.rs | 5 + fendermint/vm/message/src/lib.rs | 1 + fendermint/vm/message/src/signed.rs | 204 +++++++++++++---- 17 files changed, 549 insertions(+), 286 deletions(-) create mode 100644 fendermint/vm/message/src/conv/from_eth.rs create mode 100644 fendermint/vm/message/src/conv/from_fvm.rs create mode 100644 fendermint/vm/message/src/conv/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 04267a20..f7dff8f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2418,15 +2418,19 @@ dependencies = [ name = "fendermint_vm_message" version = "0.1.0" dependencies = [ + "anyhow", "arbitrary", "blake2b_simd", "cid", + "ethers-core", "fendermint_testing", + "fendermint_vm_actor_interface", "fendermint_vm_encoding", "fendermint_vm_message", "fvm_ipld_encoding", "fvm_shared", "hex", + "lazy_static", "libsecp256k1", "num-traits", "quickcheck 1.0.3", diff --git a/fendermint/app/Cargo.toml b/fendermint/app/Cargo.toml index d0791d0b..b7259e67 100644 --- a/fendermint/app/Cargo.toml +++ b/fendermint/app/Cargo.toml @@ -38,7 +38,7 @@ fendermint_eth_api = { path = "../eth/api" } fendermint_vm_actor_interface = { path = "../vm/actor_interface" } fendermint_vm_core = { path = "../vm/core" } fendermint_vm_interpreter = { path = "../vm/interpreter", features = ["bundle"] } -fendermint_vm_message = { path = "../vm/message", features = ["secp256k1"] } +fendermint_vm_message = { path = "../vm/message" } fendermint_vm_genesis = { path = "../vm/genesis" } cid = { workspace = true } diff --git a/fendermint/eth/api/examples/ethers.rs b/fendermint/eth/api/examples/ethers.rs index bf980783..da7a841f 100644 --- a/fendermint/eth/api/examples/ethers.rs +++ b/fendermint/eth/api/examples/ethers.rs @@ -179,9 +179,9 @@ impl TestAccount { // - eth_getTransactionByHash // - eth_getTransactionReceipt // - eth_feeHistory +// - eth_sendRawTransaction // // DOING: -// - eth_sendRawTransaction // // TODO: // - eth_newBlockFilter diff --git a/fendermint/eth/api/src/conv/from_eth.rs b/fendermint/eth/api/src/conv/from_eth.rs index 28ae7d29..c5f819c8 100644 --- a/fendermint/eth/api/src/conv/from_eth.rs +++ b/fendermint/eth/api/src/conv/from_eth.rs @@ -4,94 +4,11 @@ //! Helper methods to convert between Ethereum and FVM data formats. use anyhow::Context; -use ethers_core::types::{Eip1559TransactionRequest, NameOrAddress, H160, H256, U256}; -use fendermint_vm_actor_interface::{ - eam::{self, EthAddress}, - evm, -}; -use fvm_ipld_encoding::RawBytes; -use fvm_shared::{ - address::Address, - bigint::{BigInt, Sign}, - econ::TokenAmount, - message::Message, -}; +use ethers_core::types::H256; -// https://github.com/filecoin-project/lotus/blob/594c52b96537a8c8728389b446482a2d7ea5617c/chain/types/ethtypes/eth_transactions.go#L152 -pub fn to_fvm_message(tx: &Eip1559TransactionRequest) -> anyhow::Result { - // FIP-55 says that we should use `InvokeContract` for transfers instead of `METHOD_SEND`, - // because if we are sending to some Ethereum actor by ID using `METHOD_SEND`, they will - // get the tokens but the contract might not provide any way of retrieving them. - // The `Account` actor has been modified to accept any method call, so it will not fail - // even if it receives tokens using `InvokeContract`. - let (method_num, to) = match tx.to { - None => (eam::Method::CreateExternal as u64, eam::EAM_ACTOR_ADDR), - Some(NameOrAddress::Address(to)) => { - let to = to_fvm_address(to); - (evm::Method::InvokeContract as u64, to) - } - Some(NameOrAddress::Name(_)) => { - anyhow::bail!("Turning name to address would require ENS which is not supported.") - } - }; - - // The `from` of the transaction is inferred from the signature. - // As long as the client and the server use the same hashing scheme, - // this should be usable as a delegated address. - let from = to_fvm_address(tx.from.unwrap_or_default()); - - let msg = Message { - version: 0, - from, - to, - sequence: tx.nonce.unwrap_or_default().as_u64(), - value: to_fvm_tokens(&tx.value.unwrap_or_default()), - method_num, - params: RawBytes::new(tx.data.clone().unwrap_or_default().to_vec()), - gas_limit: tx - .gas - .map(|gas| gas.min(U256::from(u64::MAX)).as_u64()) - .unwrap_or_default(), - gas_fee_cap: to_fvm_tokens(&tx.max_fee_per_gas.unwrap_or_default()), - gas_premium: to_fvm_tokens(&tx.max_fee_per_gas.unwrap_or_default()), - }; - - Ok(msg) -} - -pub fn to_fvm_address(addr: H160) -> Address { - Address::from(EthAddress(addr.0)) -} - -pub fn to_fvm_tokens(value: &U256) -> TokenAmount { - let mut bz = [0u8; 256 / 8]; - value.to_big_endian(&mut bz); - let atto = BigInt::from_bytes_be(Sign::Plus, &bz); - TokenAmount::from_atto(atto) -} +pub use fendermint_vm_message::conv::from_eth::*; pub fn to_tm_hash(value: &H256) -> anyhow::Result { tendermint::Hash::try_from(value.as_bytes().to_vec()) .context("failed to convert to Tendermint Hash") } - -#[cfg(test)] -mod tests { - - use fendermint_testing::arb::ArbTokenAmount; - use quickcheck_macros::quickcheck; - - use crate::conv::from_fvm::to_eth_tokens; - - use super::to_fvm_tokens; - - #[quickcheck] - fn prop_to_token_amount(tokens: ArbTokenAmount) -> bool { - let tokens0 = tokens.0; - if let Ok(value) = to_eth_tokens(&tokens0) { - let tokens1 = to_fvm_tokens(&value); - return tokens0 == tokens1; - } - true - } -} diff --git a/fendermint/eth/api/src/conv/from_fvm.rs b/fendermint/eth/api/src/conv/from_fvm.rs index cddb930b..7741b598 100644 --- a/fendermint/eth/api/src/conv/from_fvm.rs +++ b/fendermint/eth/api/src/conv/from_fvm.rs @@ -3,148 +3,4 @@ //! Helper methods to convert between FVM and Ethereum data formats. -use std::str::FromStr; - -use anyhow::anyhow; -use ethers_core::types::{self as et}; -use fendermint_vm_actor_interface::eam::EthAddress; -use fendermint_vm_actor_interface::eam::EAM_ACTOR_ID; -use fvm_shared::bigint::BigInt; -use fvm_shared::crypto::signature::Signature; -use fvm_shared::crypto::signature::SignatureType; -use fvm_shared::crypto::signature::SECP_SIG_LEN; -use fvm_shared::message::Message; -use fvm_shared::{address::Payload, econ::TokenAmount}; -use lazy_static::lazy_static; -use libsecp256k1::RecoveryId; - -lazy_static! { - static ref MAX_U256: BigInt = BigInt::from_str(&et::U256::MAX.to_string()).unwrap(); -} - -pub fn to_eth_tokens(amount: &TokenAmount) -> anyhow::Result { - if amount.atto() > &MAX_U256 { - Err(anyhow!("TokenAmount > U256.MAX")) - } else { - let (_sign, bz) = amount.atto().to_bytes_be(); - Ok(et::U256::from_big_endian(&bz)) - } -} - -pub fn to_eth_from_address(msg: &Message) -> anyhow::Result { - match msg.from.payload() { - Payload::Secp256k1(h) => Ok(et::H160::from_slice(h)), - Payload::Delegated(d) if d.namespace() == EAM_ACTOR_ID && d.subaddress().len() == 20 => { - Ok(et::H160::from_slice(d.subaddress())) - } - other => Err(anyhow!("unexpected `from` address payload: {other:?}")), - } -} - -pub fn to_eth_to_address(msg: &Message) -> Option { - match msg.to.payload() { - Payload::Secp256k1(h) => Some(et::H160::from_slice(h)), - Payload::Delegated(d) if d.namespace() == EAM_ACTOR_ID && d.subaddress().len() == 20 => { - Some(et::H160::from_slice(d.subaddress())) - } - Payload::Actor(h) => Some(et::H160::from_slice(h)), - Payload::ID(id) => Some(et::H160::from_slice(&EthAddress::from_id(*id).0)), - _ => None, // BLS or an invalid delegated address. Just move on. - } -} - -fn parse_secp256k1( - sig: &[u8], -) -> anyhow::Result<(libsecp256k1::RecoveryId, libsecp256k1::Signature)> { - if sig.len() != SECP_SIG_LEN { - return Err(anyhow!("unexpected Secp256k1 length: {}", sig.len())); - } - - // generate types to recover key from - let rec_id = RecoveryId::parse(sig[64])?; - - // Signature value without recovery byte - let mut s = [0u8; 64]; - s.clone_from_slice(&sig[..64]); - - // generate Signature - let sig = libsecp256k1::Signature::parse_standard(&s)?; - - Ok((rec_id, sig)) -} - -pub fn to_eth_signature(sig: &Signature) -> anyhow::Result { - let (v, sig) = match sig.sig_type { - SignatureType::Secp256k1 => parse_secp256k1(&sig.bytes)?, - other => return Err(anyhow!("unexpected signature type: {other:?}")), - }; - - let sig = et::Signature { - v: et::U64::from(v.serialize()).as_u64(), - r: et::U256::from_big_endian(sig.r.b32().as_ref()), - s: et::U256::from_big_endian(sig.s.b32().as_ref()), - }; - - Ok(sig) -} - -#[cfg(test)] -mod tests { - - use std::str::FromStr; - - use fendermint_testing::arb::ArbTokenAmount; - use fendermint_vm_message::signed::SignedMessage; - use fvm_shared::{bigint::BigInt, chainid::ChainID, econ::TokenAmount}; - use libsecp256k1::SecretKey; - use quickcheck_macros::quickcheck; - use rand::{rngs::StdRng, SeedableRng}; - - use super::{to_eth_signature, to_eth_tokens}; - - #[quickcheck] - fn prop_to_eth_tokens(tokens: ArbTokenAmount) -> bool { - let tokens = tokens.0; - if let Ok(u256_from_tokens) = to_eth_tokens(&tokens) { - let tokens_as_str = tokens.atto().to_str_radix(10); - let u256_from_str = ethers_core::types::U256::from_dec_str(&tokens_as_str).unwrap(); - return u256_from_str == u256_from_tokens; - } - true - } - - #[test] - fn test_to_eth_tokens() { - let atto = BigInt::from_str( - "99191064924191451313862974502415542781658129482631472725645205117646186753315", - ) - .unwrap(); - - let tokens = TokenAmount::from_atto(atto); - - to_eth_tokens(&tokens).unwrap(); - } - - #[quickcheck] - fn prop_signature(msg: SignedMessage, seed: u64, chain_id: u64) -> Result<(), String> { - let chain_id = ChainID::from(chain_id); - - let mut rng = StdRng::seed_from_u64(seed); - let sk = SecretKey::random(&mut rng); - - let msg = SignedMessage::new_secp256k1(msg.into_message(), &sk, &chain_id) - .map_err(|e| format!("failed to sign: {e}"))?; - - let sig0 = msg.signature(); - - let sig1 = - to_eth_signature(sig0).map_err(|e| format!("failed to convert signature: {e}"))?; - - let sig2 = fvm_shared::crypto::signature::Signature::new_secp256k1(sig1.to_vec()); - - if *sig0 != sig2 { - return Err(format!("signatures don't match: {sig0:?} != {sig2:?}")); - } - Ok(()) - } -} +pub use fendermint_vm_message::conv::from_fvm::*; diff --git a/fendermint/eth/api/src/conv/from_tm.rs b/fendermint/eth/api/src/conv/from_tm.rs index bdc6a814..d07be396 100644 --- a/fendermint/eth/api/src/conv/from_tm.rs +++ b/fendermint/eth/api/src/conv/from_tm.rs @@ -17,7 +17,7 @@ use tendermint::abci::response::DeliverTx; use tendermint::abci::EventAttribute; use tendermint_rpc::endpoint; -use super::from_fvm::{to_eth_from_address, to_eth_signature, to_eth_to_address, to_eth_tokens}; +use super::from_fvm::{to_eth_address, to_eth_signature, to_eth_tokens}; // Values taken from https://github.com/filecoin-project/lotus/blob/6e7dc9532abdb3171427347710df4c860f1957a2/chain/types/ethtypes/eth_types.go#L199 @@ -153,8 +153,8 @@ pub fn to_eth_transaction( block_hash: None, block_number: None, transaction_index: None, - from: to_eth_from_address(&msg)?, - to: to_eth_to_address(&msg), + from: to_eth_address(&msg.from).unwrap_or_default(), + to: to_eth_address(&msg.to), value: to_eth_tokens(&msg.value)?, gas: et::U256::from(msg.gas_limit), max_fee_per_gas: Some(to_eth_tokens(&msg.gas_fee_cap)?), @@ -258,8 +258,8 @@ pub fn to_eth_receipt( transaction_index, block_hash: Some(block_hash), block_number: Some(block_number), - from: to_eth_from_address(&msg)?, - to: to_eth_to_address(&msg), + from: to_eth_address(&msg.from).unwrap_or_default(), + to: to_eth_address(&msg.to), cumulative_gas_used, gas_used: Some(et::U256::from(result.tx_result.gas_used)), contract_address, diff --git a/fendermint/rpc/Cargo.toml b/fendermint/rpc/Cargo.toml index f63951ff..82390b55 100644 --- a/fendermint/rpc/Cargo.toml +++ b/fendermint/rpc/Cargo.toml @@ -24,7 +24,7 @@ fvm_ipld_encoding = { workspace = true } fvm_shared = { workspace = true } fendermint_vm_actor_interface = { path = "../vm/actor_interface" } -fendermint_vm_message = { path = "../vm/message", features = ["secp256k1"] } +fendermint_vm_message = { path = "../vm/message" } [dev-dependencies] clap = { workspace = true } diff --git a/fendermint/rpc/src/message.rs b/fendermint/rpc/src/message.rs index dd26feef..9df9afc9 100644 --- a/fendermint/rpc/src/message.rs +++ b/fendermint/rpc/src/message.rs @@ -17,6 +17,9 @@ use libsecp256k1::{PublicKey, SecretKey}; use crate::B64_ENGINE; /// Factory methods for signed transaction payload construction. +/// +/// It assumes the sender is an `f1` type address, it won't work with `f410` addresses. +/// For those one must use the Ethereum API, with a suitable client library such as [ethers]. pub struct MessageFactory { sk: SecretKey, addr: Address, diff --git a/fendermint/testing/src/arb/mod.rs b/fendermint/testing/src/arb/mod.rs index 455e54a1..70f22f57 100644 --- a/fendermint/testing/src/arb/mod.rs +++ b/fendermint/testing/src/arb/mod.rs @@ -4,6 +4,7 @@ use fvm_shared::{ address::Address, bigint::{BigInt, Integer, Sign, MAX_BIGINT_SIZE}, econ::TokenAmount, + message::Message, }; use quickcheck::{Arbitrary, Gen}; @@ -38,3 +39,18 @@ impl Arbitrary for ArbAddress { Self(Address::from_bytes(&bz).unwrap()) } } + +#[derive(Clone, Debug)] +pub struct ArbMessage(pub Message); + +impl Arbitrary for ArbMessage { + fn arbitrary(g: &mut Gen) -> Self { + let mut message = Message::arbitrary(g); + message.gas_fee_cap = ArbTokenAmount::arbitrary(g).0; + message.gas_premium = ArbTokenAmount::arbitrary(g).0; + message.value = ArbTokenAmount::arbitrary(g).0; + message.to = ArbAddress::arbitrary(g).0; + message.from = ArbAddress::arbitrary(g).0; + Self(message) + } +} diff --git a/fendermint/vm/actor_interface/src/eam.rs b/fendermint/vm/actor_interface/src/eam.rs index fb7465ac..83e9470d 100644 --- a/fendermint/vm/actor_interface/src/eam.rs +++ b/fendermint/vm/actor_interface/src/eam.rs @@ -50,11 +50,16 @@ impl EthAddress { hash20.copy_from_slice(&hash32.digest()[12..]); Ok(Self(hash20)) } + + /// Indicate whether this hash is really an actor ID. + pub fn is_masked_id(&self) -> bool { + self.0[0] == 0xff && self.0[1..].starts_with(&[0u8; 11]) + } } impl From for Address { fn from(value: EthAddress) -> Address { - if value.0[0] == 0xff { + if value.is_masked_id() { let mut bytes = [0u8; 8]; bytes.copy_from_slice(&value.0[12..]); let id = u64::from_be_bytes(bytes); diff --git a/fendermint/vm/interpreter/src/signed.rs b/fendermint/vm/interpreter/src/signed.rs index 8c22fb4a..45568312 100644 --- a/fendermint/vm/interpreter/src/signed.rs +++ b/fendermint/vm/interpreter/src/signed.rs @@ -53,6 +53,7 @@ where match msg.verify(chain_id) { Err(SignedMessageError::Ipld(e)) => Err(anyhow!(e)), + Err(SignedMessageError::Ethereum(e)) => Err(e), Err(SignedMessageError::InvalidSignature(s)) => { // TODO: We can penalize the validator for including an invalid signature. Ok((state, Err(InvalidSignature(s)))) @@ -97,6 +98,7 @@ where match verify_result { Err(SignedMessageError::Ipld(e)) => Err(anyhow!(e)), + Err(SignedMessageError::Ethereum(e)) => Err(e), Err(SignedMessageError::InvalidSignature(s)) => { // There is nobody we can punish for this, we can just tell Tendermint to discard this message, // and potentially block the source IP address. diff --git a/fendermint/vm/message/Cargo.toml b/fendermint/vm/message/Cargo.toml index 328d06fa..874f3d7b 100644 --- a/fendermint/vm/message/Cargo.toml +++ b/fendermint/vm/message/Cargo.toml @@ -7,6 +7,11 @@ edition.workspace = true license.workspace = true [dependencies] +anyhow = { workspace = true } +blake2b_simd = { workspace = true } +ethers-core = { workspace = true } +lazy_static = { workspace = true } +libsecp256k1 = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_tuple = { workspace = true } @@ -17,14 +22,13 @@ cid = { workspace = true } fvm_shared = { workspace = true } fvm_ipld_encoding = { workspace = true } -libsecp256k1 = { workspace = true, optional = true } -blake2b_simd = { workspace = true, optional = true } arbitrary = { workspace = true, optional = true } quickcheck = { workspace = true, optional = true } rand = { workspace = true, optional = true } fendermint_vm_encoding = { path = "../encoding" } +fendermint_vm_actor_interface = { path = "../actor_interface" } fendermint_testing = { path = "../../testing", optional = true } [dev-dependencies] @@ -36,10 +40,8 @@ hex = { workspace = true } # Ideally we could do this with `#[cfg(any(test, feature = "arb"))]`, # however in that case all the extra dependencies would not kick in, # and we'd have to repeat all those dependencies. -fendermint_vm_message = { path = ".", features = ["arb", "secp256k1"] } +fendermint_vm_message = { path = ".", features = ["arb"] } fendermint_testing = { path = "../../testing", features = ["golden"] } [features] -default = ["secp256k1"] arb = ["arbitrary", "quickcheck", "fvm_shared/arb", "cid/arb", "rand", "fendermint_testing/arb"] -secp256k1 = ["libsecp256k1", "blake2b_simd"] diff --git a/fendermint/vm/message/src/conv/from_eth.rs b/fendermint/vm/message/src/conv/from_eth.rs new file mode 100644 index 00000000..f45822aa --- /dev/null +++ b/fendermint/vm/message/src/conv/from_eth.rs @@ -0,0 +1,91 @@ +// Copyright 2022-2023 Protocol Labs +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Helper methods to convert between Ethereum and FVM data formats. + +use ethers_core::types::{Eip1559TransactionRequest, NameOrAddress, H160, U256}; +use fendermint_vm_actor_interface::{ + eam::{self, EthAddress}, + evm, +}; +use fvm_ipld_encoding::RawBytes; +use fvm_shared::{ + address::Address, + bigint::{BigInt, Sign}, + econ::TokenAmount, + message::Message, +}; + +// https://github.com/filecoin-project/lotus/blob/594c52b96537a8c8728389b446482a2d7ea5617c/chain/types/ethtypes/eth_transactions.go#L152 +pub fn to_fvm_message(tx: &Eip1559TransactionRequest) -> anyhow::Result { + // FIP-55 says that we should use `InvokeContract` for transfers instead of `METHOD_SEND`, + // because if we are sending to some Ethereum actor by ID using `METHOD_SEND`, they will + // get the tokens but the contract might not provide any way of retrieving them. + // The `Account` actor has been modified to accept any method call, so it will not fail + // even if it receives tokens using `InvokeContract`. + let (method_num, to) = match tx.to { + None => (eam::Method::CreateExternal as u64, eam::EAM_ACTOR_ADDR), + Some(NameOrAddress::Address(to)) => { + let to = to_fvm_address(to); + (evm::Method::InvokeContract as u64, to) + } + Some(NameOrAddress::Name(_)) => { + anyhow::bail!("Turning name to address would require ENS which is not supported.") + } + }; + + // The `from` of the transaction is inferred from the signature. + // As long as the client and the server use the same hashing scheme, + // this should be usable as a delegated address. + let from = to_fvm_address(tx.from.unwrap_or_default()); + + let msg = Message { + version: 0, + from, + to, + sequence: tx.nonce.unwrap_or_default().as_u64(), + value: to_fvm_tokens(&tx.value.unwrap_or_default()), + method_num, + params: RawBytes::new(tx.data.clone().unwrap_or_default().to_vec()), + gas_limit: tx + .gas + .map(|gas| gas.min(U256::from(u64::MAX)).as_u64()) + .unwrap_or_default(), + gas_fee_cap: to_fvm_tokens(&tx.max_fee_per_gas.unwrap_or_default()), + gas_premium: to_fvm_tokens(&tx.max_priority_fee_per_gas.unwrap_or_default()), + }; + + Ok(msg) +} + +pub fn to_fvm_address(addr: H160) -> Address { + Address::from(EthAddress(addr.0)) +} + +pub fn to_fvm_tokens(value: &U256) -> TokenAmount { + let mut bz = [0u8; 256 / 8]; + value.to_big_endian(&mut bz); + let atto = BigInt::from_bytes_be(Sign::Plus, &bz); + TokenAmount::from_atto(atto) +} + +#[cfg(test)] +mod tests { + + use fendermint_testing::arb::ArbTokenAmount; + use quickcheck_macros::quickcheck; + + use crate::conv::from_fvm::to_eth_tokens; + + use super::to_fvm_tokens; + + #[quickcheck] + fn prop_to_token_amount(tokens: ArbTokenAmount) -> bool { + let tokens0 = tokens.0; + if let Ok(value) = to_eth_tokens(&tokens0) { + let tokens1 = to_fvm_tokens(&value); + return tokens0 == tokens1; + } + true + } +} diff --git a/fendermint/vm/message/src/conv/from_fvm.rs b/fendermint/vm/message/src/conv/from_fvm.rs new file mode 100644 index 00000000..557f43ba --- /dev/null +++ b/fendermint/vm/message/src/conv/from_fvm.rs @@ -0,0 +1,241 @@ +// Copyright 2022-2023 Protocol Labs +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Helper methods to convert between FVM and Ethereum data formats. + +use std::str::FromStr; + +use anyhow::anyhow; +use ethers_core::types as et; +use ethers_core::types::transaction::eip2718::TypedTransaction; +use fendermint_vm_actor_interface::eam::EthAddress; +use fendermint_vm_actor_interface::eam::EAM_ACTOR_ID; +use fvm_shared::address::Address; +use fvm_shared::bigint::BigInt; +use fvm_shared::chainid::ChainID; +use fvm_shared::crypto::signature::Signature; +use fvm_shared::crypto::signature::SignatureType; +use fvm_shared::crypto::signature::SECP_SIG_LEN; +use fvm_shared::message::Message; +use fvm_shared::{address::Payload, econ::TokenAmount}; +use lazy_static::lazy_static; +use libsecp256k1::RecoveryId; + +lazy_static! { + pub static ref MAX_U256: BigInt = BigInt::from_str(&et::U256::MAX.to_string()).unwrap(); +} + +pub fn to_eth_tokens(amount: &TokenAmount) -> anyhow::Result { + if amount.atto() > &MAX_U256 { + Err(anyhow!("TokenAmount > U256.MAX")) + } else { + let (_sign, bz) = amount.atto().to_bytes_be(); + Ok(et::U256::from_big_endian(&bz)) + } +} + +pub fn to_eth_address(addr: &Address) -> Option { + match addr.payload() { + Payload::Delegated(d) if d.namespace() == EAM_ACTOR_ID && d.subaddress().len() == 20 => { + Some(et::H160::from_slice(d.subaddress())) + } + // It should be possible to send to an ethereum account by ID. + Payload::ID(id) => Some(et::H160::from_slice(&EthAddress::from_id(*id).0)), + // XXX: The following fit into the type but are not valid ethereum addresses. + Payload::Secp256k1(h) => Some(et::H160::from_slice(h)), + Payload::Actor(h) => Some(et::H160::from_slice(h)), + _ => None, // BLS or an invalid delegated address. Just move on. + } +} + +fn parse_secp256k1( + sig: &[u8], +) -> anyhow::Result<(libsecp256k1::RecoveryId, libsecp256k1::Signature)> { + if sig.len() != SECP_SIG_LEN { + return Err(anyhow!("unexpected Secp256k1 length: {}", sig.len())); + } + + // generate types to recover key from + let rec_id = RecoveryId::parse(sig[64])?; + + // Signature value without recovery byte + let mut s = [0u8; 64]; + s.clone_from_slice(&sig[..64]); + + // generate Signature + let sig = libsecp256k1::Signature::parse_standard(&s)?; + + Ok((rec_id, sig)) +} + +pub fn to_eth_signature(sig: &Signature) -> anyhow::Result { + let (v, sig) = match sig.sig_type { + SignatureType::Secp256k1 => parse_secp256k1(&sig.bytes)?, + other => return Err(anyhow!("unexpected signature type: {other:?}")), + }; + + let sig = et::Signature { + v: et::U64::from(v.serialize()).as_u64(), + r: et::U256::from_big_endian(sig.r.b32().as_ref()), + s: et::U256::from_big_endian(sig.s.b32().as_ref()), + }; + + Ok(sig) +} + +pub fn to_eth_transaction(msg: &Message, chain_id: &ChainID) -> anyhow::Result { + let chain_id: u64 = (*chain_id).into(); + + let Message { + version: _, + from, + to, + sequence, + value, + method_num: _, + params, + gas_limit, + gas_fee_cap, + gas_premium, + } = msg; + + let mut tx = et::Eip1559TransactionRequest::new() + .chain_id(chain_id) + .from(to_eth_address(from).unwrap_or_default()) + .nonce(*sequence) + .value(to_eth_tokens(value)?) + .gas(*gas_limit) + .max_fee_per_gas(to_eth_tokens(gas_fee_cap)?) + .max_priority_fee_per_gas(to_eth_tokens(gas_premium)?) + .data(et::Bytes::from(params.to_vec())); + + tx.to = to_eth_address(to).map(et::NameOrAddress::Address); + + Ok(tx.into()) +} + +#[cfg(test)] +pub mod tests { + + use std::{array, str::FromStr}; + + use fendermint_testing::arb::{ArbMessage, ArbTokenAmount}; + use fendermint_vm_actor_interface::{ + eam::{EthAddress, EAM_ACTOR_ID}, + evm, + }; + use fendermint_vm_message::signed::SignedMessage; + use fvm_shared::{ + address::Address, + bigint::{BigInt, Integer}, + chainid::ChainID, + econ::TokenAmount, + message::Message, + }; + use libsecp256k1::SecretKey; + use quickcheck_macros::quickcheck; + use rand::{rngs::StdRng, SeedableRng}; + + use crate::conv::from_eth::to_fvm_message; + + use super::{to_eth_signature, to_eth_tokens, to_eth_transaction, MAX_U256}; + + #[derive(Clone, Debug)] + struct EthDelegatedAddress(Address); + + impl quickcheck::Arbitrary for EthDelegatedAddress { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + let mut subaddr: [u8; 20] = array::from_fn(|_| u8::arbitrary(g)); + while EthAddress(subaddr).is_masked_id() { + subaddr[0] = u8::arbitrary(g); + } + Self(Address::new_delegated(EAM_ACTOR_ID, &subaddr).unwrap()) + } + } + + #[derive(Clone, Debug)] + struct EthTokenAmount(TokenAmount); + + impl quickcheck::Arbitrary for EthTokenAmount { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + let t = ArbTokenAmount::arbitrary(g).0; + let (_, t) = t.atto().div_mod_floor(&MAX_U256); + Self(TokenAmount::from_atto(t)) + } + } + + /// Message that only contains data which can survive a roundtrip. + #[derive(Clone, Debug)] + pub struct EthMessage(pub Message); + + impl quickcheck::Arbitrary for EthMessage { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + let mut m = ArbMessage::arbitrary(g).0; + m.version = 0; + m.method_num = evm::Method::InvokeContract as u64; + m.from = EthDelegatedAddress::arbitrary(g).0; + m.to = EthDelegatedAddress::arbitrary(g).0; + m.value = EthTokenAmount::arbitrary(g).0; + m.gas_fee_cap = EthTokenAmount::arbitrary(g).0; + m.gas_premium = EthTokenAmount::arbitrary(g).0; + Self(m) + } + } + + #[quickcheck] + fn prop_to_eth_tokens(tokens: ArbTokenAmount) -> bool { + let tokens = tokens.0; + if let Ok(u256_from_tokens) = to_eth_tokens(&tokens) { + let tokens_as_str = tokens.atto().to_str_radix(10); + let u256_from_str = ethers_core::types::U256::from_dec_str(&tokens_as_str).unwrap(); + return u256_from_str == u256_from_tokens; + } + true + } + + #[test] + fn test_to_eth_tokens() { + let atto = BigInt::from_str( + "99191064924191451313862974502415542781658129482631472725645205117646186753315", + ) + .unwrap(); + + let tokens = TokenAmount::from_atto(atto); + + to_eth_tokens(&tokens).unwrap(); + } + + #[quickcheck] + fn prop_signature(msg: SignedMessage, seed: u64, chain_id: u64) -> Result<(), String> { + let chain_id = ChainID::from(chain_id); + + let mut rng = StdRng::seed_from_u64(seed); + let sk = SecretKey::random(&mut rng); + + let msg = SignedMessage::new_secp256k1(msg.into_message(), &sk, &chain_id) + .map_err(|e| format!("failed to sign: {e}"))?; + + let sig0 = msg.signature(); + + let sig1 = + to_eth_signature(sig0).map_err(|e| format!("failed to convert signature: {e}"))?; + + let sig2 = fvm_shared::crypto::signature::Signature::new_secp256k1(sig1.to_vec()); + + if *sig0 != sig2 { + return Err(format!("signatures don't match: {sig0:?} != {sig2:?}")); + } + Ok(()) + } + + #[quickcheck] + fn prop_to_and_from_eth_transaction(msg: EthMessage, chain_id: u64) { + let chain_id = ChainID::from(chain_id); + let msg0 = msg.0; + let tx = to_eth_transaction(&msg0, &chain_id).unwrap(); + let tx = tx.as_eip1559_ref().unwrap(); + let msg1 = to_fvm_message(tx).unwrap(); + + assert_eq!(msg1, msg0) + } +} diff --git a/fendermint/vm/message/src/conv/mod.rs b/fendermint/vm/message/src/conv/mod.rs new file mode 100644 index 00000000..43d11b6d --- /dev/null +++ b/fendermint/vm/message/src/conv/mod.rs @@ -0,0 +1,5 @@ +// Copyright 2022-2023 Protocol Labs +// SPDX-License-Identifier: Apache-2.0, MIT + +pub mod from_eth; +pub mod from_fvm; diff --git a/fendermint/vm/message/src/lib.rs b/fendermint/vm/message/src/lib.rs index 7de5f14c..4fe8612b 100644 --- a/fendermint/vm/message/src/lib.rs +++ b/fendermint/vm/message/src/lib.rs @@ -5,6 +5,7 @@ use fvm_ipld_encoding::{to_vec, Error as IpldError, DAG_CBOR}; use serde::Serialize; pub mod chain; +pub mod conv; pub mod query; pub mod signed; diff --git a/fendermint/vm/message/src/signed.rs b/fendermint/vm/message/src/signed.rs index 42c85d1c..614b2ecc 100644 --- a/fendermint/vm/message/src/signed.rs +++ b/fendermint/vm/message/src/signed.rs @@ -2,21 +2,35 @@ // Copyright 2019-2022 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +use anyhow::anyhow; use cid::Cid; +use ethers_core::types as et; +use fendermint_vm_actor_interface::{eam, evm}; use fvm_ipld_encoding::tuple::{Deserialize_tuple, Serialize_tuple}; -use fvm_shared::address::Protocol; +use fvm_shared::address::{Address, Payload}; use fvm_shared::chainid::ChainID; -use fvm_shared::crypto::signature::{Signature, SignatureType}; +use fvm_shared::crypto::signature::{Signature, SignatureType, SECP_SIG_LEN}; use fvm_shared::message::Message; use thiserror::Error; +use crate::conv::from_fvm; + +enum Signable { + /// Pair of transaction hash and from. + Ethereum((et::H256, et::H160)), + /// Bytes to be passed to the FVM Signature for hashing or verification. + Regular(Vec), +} + #[derive(Error, Debug)] pub enum SignedMessageError { #[error("message cannot be serialized")] Ipld(#[from] fvm_ipld_encoding::Error), #[error("invalid signature: {0}")] InvalidSignature(String), + #[error("message cannot be converted to ethereum")] + Ethereum(#[from] anyhow::Error), } /// Represents a wrapped message with signature bytes. @@ -41,16 +55,18 @@ impl SignedMessage { } /// Create a signed message. - #[cfg(feature = "secp256k1")] pub fn new_secp256k1( message: Message, sk: &libsecp256k1::SecretKey, chain_id: &ChainID, - ) -> Result { - let data = Self::bytes_to_sign(&message, chain_id)?; + ) -> Result { + let sig = match Self::signable(&message, chain_id)? { + Signable::Ethereum((hash, _)) => sign_eth(sk, hash).to_vec(), + Signable::Regular(data) => sign_regular(sk, &data).to_vec(), + }; let signature = Signature { sig_type: SignatureType::Secp256k1, - bytes: sign_secp256k1(sk, &data).to_vec(), + bytes: sig, }; Ok(Self { message, signature }) } @@ -64,13 +80,32 @@ impl SignedMessage { /// /// The [`ChainID`] is used as a replay attack protection, a variation of /// https://github.com/filecoin-project/FIPs/blob/master/FIPS/fip-0039.md - pub fn bytes_to_sign( - message: &Message, - chain_id: &ChainID, - ) -> Result, fvm_ipld_encoding::Error> { - let mut data = Self::cid(message)?.to_bytes(); - data.extend(chain_id_bytes(chain_id).iter()); - Ok(data) + fn signable(message: &Message, chain_id: &ChainID) -> Result { + // Here we look at the sender to decide what scheme to use for hashing. + // + // This is in contrast to https://github.com/filecoin-project/FIPs/blob/master/FIPS/fip-0055.md#delegated-signature-type + // which introduces a `SignatureType::Delegated`, in which case the signature check should be done by the recipient actor. + // + // However, that isn't implemented, and adding that type would mean copying the entire `Signature` type into Fendermint, + // similarly to how Forest did it https://github.com/ChainSafe/forest/blob/b3c5efe6cc81607da945227bb41c60cec47909c3/utils/forest_shim/src/crypto.rs#L166 + // + // Instead of special casing on the signature type, we are special casing on the sender, + // which should be okay because the CLI only uses `f1` addresses and the Ethereum API only uses `f410` addresses, + // so at least for now they are easy to tell apart: any `f410` address is coming from Ethereum API and must have + // been signed according to the Ethereum scheme, and it could not have been signed by an `f1` address, it doesn't + // work with regular accounts. + match maybe_eth_address(&message.from) { + Some(addr) => { + let tx = from_fvm::to_eth_transaction(message, chain_id) + .map_err(SignedMessageError::Ethereum)?; + Ok(Signable::Ethereum((tx.sighash(), addr))) + } + None => { + let mut data = Self::cid(message)?.to_bytes(); + data.extend(chain_id_bytes(chain_id).iter()); + Ok(Signable::Regular(data)) + } + } } /// Verify that the message CID was signed by the `from` address. @@ -79,15 +114,30 @@ impl SignedMessage { signature: &Signature, chain_id: &ChainID, ) -> Result<(), SignedMessageError> { - if message.from.protocol() == Protocol::Delegated { - // TODO: https://github.com/consensus-shipyard/fendermint/issues/114 - return Ok(()); + match Self::signable(message, chain_id)? { + Signable::Ethereum((hash, from)) => { + // If the sender is ethereum, recover the public key from the signature (which verifies it), + // then turn it into an `EthAddress` and verify it matches the `from` of the message. + let sig = + from_fvm::to_eth_signature(signature).map_err(SignedMessageError::Ethereum)?; + + let rec = sig + .recover(hash) + .map_err(|e| SignedMessageError::Ethereum(anyhow!(e)))?; + + if rec == from { + verify_eth_method(message) + } else { + Err(SignedMessageError::InvalidSignature("the Ethereum delegated address did not match the one recovered from the signature".into())) + } + } + Signable::Regular(data) => { + // This works when `from` corresponds to the signature type. + signature + .verify(&data, &message.from) + .map_err(SignedMessageError::InvalidSignature) + } } - let data = Self::bytes_to_sign(message, chain_id)?; - - signature - .verify(&data, &message.from) - .map_err(SignedMessageError::InvalidSignature) } /// Verifies that the from address of the message generated the signature. @@ -121,11 +171,8 @@ impl SignedMessage { } } -#[cfg(feature = "secp256k1")] -fn sign_secp256k1( - sk: &libsecp256k1::SecretKey, - data: &[u8], -) -> [u8; fvm_shared::crypto::signature::SECP_SIG_LEN] { +/// Sign a transaction pre-image using Blake2b256, in a way that [Signature::verify] expects it. +fn sign_regular(sk: &libsecp256k1::SecretKey, data: &[u8]) -> [u8; SECP_SIG_LEN] { let hash: [u8; 32] = blake2b_simd::Params::new() .hash_length(32) .to_state() @@ -135,9 +182,19 @@ fn sign_secp256k1( .try_into() .unwrap(); - let (sig, recovery_id) = libsecp256k1::sign(&libsecp256k1::Message::parse(&hash), sk); + sign_secp256k1(sk, &hash) +} + +/// Sign a transaction pre-image in the same way Ethereum clients would sign it. +fn sign_eth(sk: &libsecp256k1::SecretKey, hash: et::H256) -> [u8; SECP_SIG_LEN] { + sign_secp256k1(sk, &hash.0) +} + +/// Sign a hash using the secret key. +fn sign_secp256k1(sk: &libsecp256k1::SecretKey, hash: &[u8; 32]) -> [u8; SECP_SIG_LEN] { + let (sig, recovery_id) = libsecp256k1::sign(&libsecp256k1::Message::parse(hash), sk); - let mut signature = [0u8; fvm_shared::crypto::signature::SECP_SIG_LEN]; + let mut signature = [0u8; SECP_SIG_LEN]; signature[..64].copy_from_slice(&sig.serialize()); signature[64] = recovery_id.serialize(); signature @@ -148,26 +205,50 @@ fn chain_id_bytes(chain_id: &ChainID) -> [u8; 8] { u64::from(*chain_id).to_be_bytes() } +/// Return the 20 byte Ethereum address if the address is that kind of delegated one. +fn maybe_eth_address(addr: &Address) -> Option { + match addr.payload() { + Payload::Delegated(addr) + if addr.namespace() == eam::EAM_ACTOR_ID && addr.subaddress().len() == 20 => + { + Some(et::H160::from_slice(addr.subaddress())) + } + _ => None, + } +} + +/// Verify that the method ID and the recipient are one of the allowed combination, +/// which for example is set by [from_eth::to_fvm_message]. +/// +/// The method ID is not part of the signature, so someone could modify it, which is +/// why we have to check explicitly that there is nothing untowards going on. +fn verify_eth_method(msg: &Message) -> Result<(), SignedMessageError> { + if msg.to == eam::EAM_ACTOR_ADDR && msg.method_num != eam::Method::CreateExternal as u64 { + Err(SignedMessageError::Ethereum(anyhow!( + "The EAM actor can only be called with CreateExternal" + ))) + } else if msg.method_num != evm::Method::InvokeContract as u64 { + Err(SignedMessageError::Ethereum(anyhow!( + "An EVM actor can only be called with InvokeContract" + ))) + } else { + Ok(()) + } +} + /// Signed message with an invalid random signature. #[cfg(feature = "arb")] mod arb { - use fendermint_testing::arb::{ArbAddress, ArbTokenAmount}; - use fvm_shared::{crypto::signature::Signature, message::Message}; + use fendermint_testing::arb::ArbMessage; + use fvm_shared::crypto::signature::Signature; use super::SignedMessage; /// An arbitrary `SignedMessage` that is at least as consistent as required for serialization. impl quickcheck::Arbitrary for SignedMessage { fn arbitrary(g: &mut quickcheck::Gen) -> Self { - let mut message = Message::arbitrary(g); - message.gas_fee_cap = ArbTokenAmount::arbitrary(g).0; - message.gas_premium = ArbTokenAmount::arbitrary(g).0; - message.value = ArbTokenAmount::arbitrary(g).0; - message.to = ArbAddress::arbitrary(g).0; - message.from = ArbAddress::arbitrary(g).0; - Self { - message, + message: ArbMessage::arbitrary(g).0, signature: Signature::arbitrary(g), } } @@ -176,17 +257,38 @@ mod arb { #[cfg(test)] mod tests { + use fendermint_vm_actor_interface::eam::EthAddress; use fvm_shared::{address::Address, chainid::ChainID}; use quickcheck_macros::quickcheck; use rand::{rngs::StdRng, SeedableRng}; + use crate::conv::from_fvm::tests::EthMessage; + use super::SignedMessage; + #[derive(Debug, Clone)] + struct KeyPair { + sk: libsecp256k1::SecretKey, + pk: libsecp256k1::PublicKey, + } + + impl quickcheck::Arbitrary for KeyPair { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + let seed = u64::arbitrary(g); + let mut rng = StdRng::seed_from_u64(seed); + let sk = libsecp256k1::SecretKey::random(&mut rng); + let pk = libsecp256k1::PublicKey::from_secret_key(&sk); + Self { sk, pk } + } + } + #[quickcheck] - fn chain_id_in_signature(msg: SignedMessage, chain_id: u64, seed: u64) -> Result<(), String> { - let mut rng = StdRng::seed_from_u64(seed); - let sk = libsecp256k1::SecretKey::random(&mut rng); - let pk = libsecp256k1::PublicKey::from_secret_key(&sk); + fn chain_id_in_signature( + msg: SignedMessage, + chain_id: u64, + key: KeyPair, + ) -> Result<(), String> { + let KeyPair { sk, pk } = key; let chain_id0 = ChainID::from(chain_id); let chain_id1 = ChainID::from(chain_id.overflowing_add(1).0); @@ -207,4 +309,22 @@ mod tests { } Ok(()) } + + #[quickcheck] + fn eth_sign_and_verify(msg: EthMessage, chain_id: u64, key: KeyPair) -> Result<(), String> { + let chain_id = ChainID::from(chain_id); + let KeyPair { sk, pk } = key; + + // Set the message to the address we are going to sign with. + let ea = EthAddress::new_secp256k1(&pk.serialize()).map_err(|e| e.to_string())?; + let mut msg = msg.0; + msg.from = Address::from(ea); + + eprintln!("from = {:?}", msg.from); + + let signed = + SignedMessage::new_secp256k1(msg, &sk, &chain_id).map_err(|e| e.to_string())?; + + signed.verify(&chain_id).map_err(|e| e.to_string()) + } }