Skip to content

Commit

Permalink
Add initial abstraction for signers and wallets (#40)
Browse files Browse the repository at this point in the history
* Add initial abstraction for signers and wallets

Two new main abstractions are introduced here: signers and wallets.

Wallets can be created from a secret key and used to sign messages and,
eventually, transactions. The signer trait is generic enough to enable
different sources to be signers, such as local wallets, trezor, ledger,
metamask, etc. These changes enable only the creation of local wallets.

All encryption is done through `secp256k1`.

Here's an example of the new capability:

```Rust
// Generate your secret key
let mut rng = StdRng::seed_from_u64(2322u64);
let mut secret_seed = [0u8; 32];
rng.fill_bytes(&mut secret_seed);

let secret =
    SecretKey::from_slice(&secret_seed)
                        .expect("Failed to generate random secret!");

// Create a new local wallet with the newly generated key
let wallet = LocalWallet::new_from_private_key(secret)?;

let message = "my message";
let signature = wallet.sign_message(message.as_bytes()).await?;

// Recover address that signed the message
let recovered_address = signature.recover(message).unwrap();

assert_eq!(wallet.address(), recovered_address);

// Verify signature
signature.verify(message, recovered_address).unwrap();
```
  • Loading branch information
digorithm committed Jan 17, 2022
1 parent 210a14c commit 40d9b6b
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 2 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Expand Up @@ -4,5 +4,6 @@ members = [
"fuels-abi-cli",
"fuels-abigen-macro",
"fuels-rs",
"fuels-signers",
"fuels-core",
]
6 changes: 6 additions & 0 deletions fuels-core/Cargo.toml
Expand Up @@ -9,10 +9,16 @@ repository = "https://github.com/FuelLabs/fuels-rs"
description = "Fuel Rust SDK core."

[dependencies]
fuel-tx = "0.1"
fuel-types = { version = "0.1", default-features = false }
fuel-vm = "0.1"
hex = { version = "0.4.3", default-features = false, features = ["std"] }
rand = { version = "0.8.4", default-features = false }
serde = { version = "1.0.124", default-features = true, features = ["derive"] }
sha2 = { version = "0.9.5", optional = true }
strum = "0.21"
strum_macros = "0.21"
thiserror = { version = "1.0.30", default-features = false }

[dev-dependencies]
hex = { version = "0.4.3", default-features = false, features = ["std"] }
Expand Down
1 change: 1 addition & 0 deletions fuels-core/src/lib.rs
Expand Up @@ -7,6 +7,7 @@ pub mod abi_decoder;
#[cfg(not(feature = "no-std"))]
pub mod abi_encoder;
pub mod errors;
pub mod signature;

pub type ByteArray = [u8; 8];
pub type Selector = ByteArray;
Expand Down
198 changes: 198 additions & 0 deletions fuels-core/src/signature.rs
@@ -0,0 +1,198 @@
use crate::Bits256;
use fuel_tx::{crypto::Hasher, Bytes64};
use fuel_types::Address;
use fuel_vm::crypto::secp256k1_sign_compact_recover;
use std::{convert::TryFrom, fmt, str::FromStr};
use thiserror::Error;

/// An error involving a signature.
#[derive(Debug, Error)]
pub enum SignatureError {
/// Invalid length
#[error("invalid signature length, got {0}, expected 64")]
InvalidLength(usize),
/// When parsing a signature from string to hex
#[error(transparent)]
DecodingError(#[from] hex::FromHexError),
/// Thrown when signature verification failed (i.e. when the address that
/// produced the signature did not match the expected address)
#[error("Signature verification failed. Expected {0}, got {1}")]
VerificationError(Address, Address),
/// Error in recovering public key from signature
#[error("Public key recovery error")]
RecoveryError,
}

/// Recovery message data.
#[derive(Clone, Debug, PartialEq)]
pub enum RecoveryMessage {
/// Message bytes
Data(Vec<u8>),
/// Message hash
Hash(Bits256),
}

#[derive(Debug, Clone, PartialEq, Eq, Copy)]
/// An ECDSA signature. Encoded as:
///
/// ```plaintext
/// | 32 bytes || 32 bytes |
/// [256-bit r value][1-bit v value][255-bit s value]
/// ```
///
/// The encoding of the signature was derived from
/// [Compact Signature Representation](https://eips.ethereum.org/EIPS/eip-2098).
///
/// Signatures are represented as the `r` and `s` (each 32 bytes),
/// and `v` (1-bit) values of the signature. `r` and `s` take on
/// their usual meaning while `v` is used for recovering the public
/// key from a signature more quickly.
pub struct Signature {
pub compact: Bytes64,
}

impl fmt::Display for Signature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", hex::encode(self.compact))
}
}

impl Signature {
/// Verifies that signature on `message` was produced by `address`
pub fn verify<M, A>(&self, message: M, address: A) -> Result<(), SignatureError>
where
M: Into<RecoveryMessage>,
A: Into<Address>,
{
let address = address.into();
let recovered = self.recover(message)?;

if recovered != address {
return Err(SignatureError::VerificationError(address, recovered));
}

Ok(())
}

/// Recovers the Fuel address which was used
/// to sign the given message. Note that this message
/// can be either the original message or its digest (hashed message).
/// Both can be used to recover the address of the signature.
pub fn recover<M>(&self, message: M) -> Result<Address, SignatureError>
where
M: Into<RecoveryMessage>,
{
let message = message.into();
let message_hash = match message {
RecoveryMessage::Data(ref message) => Hasher::hash(&message[..]),
RecoveryMessage::Hash(hash) => hash.into(),
};

let recovered =
secp256k1_sign_compact_recover(self.compact.as_ref(), message_hash.as_ref()).unwrap();

let hashed = Hasher::hash(recovered);

Ok(Address::new(*hashed))
}
}

impl<'a> TryFrom<&'a [u8]> for Signature {
type Error = SignatureError;

fn try_from(bytes: &'a [u8]) -> Result<Self, Self::Error> {
if bytes.len() != 64 {
return Err(SignatureError::InvalidLength(bytes.len()));
}

Ok(Signature {
compact: unsafe { Bytes64::from_slice_unchecked(bytes) },
})
}
}

impl FromStr for Signature {
type Err = SignatureError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.strip_prefix("0x").unwrap_or(s);
let bytes = hex::decode(s)?;
Signature::try_from(&bytes[..])
}
}

impl From<&[u8]> for RecoveryMessage {
fn from(s: &[u8]) -> Self {
s.to_owned().into()
}
}

impl From<Vec<u8>> for RecoveryMessage {
fn from(s: Vec<u8>) -> Self {
RecoveryMessage::Data(s)
}
}

impl From<&str> for RecoveryMessage {
fn from(s: &str) -> Self {
s.as_bytes().to_owned().into()
}
}

impl From<String> for RecoveryMessage {
fn from(s: String) -> Self {
RecoveryMessage::Data(s.into_bytes())
}
}

impl From<[u8; 32]> for RecoveryMessage {
fn from(hash: [u8; 32]) -> Self {
RecoveryMessage::Hash(hash)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn verify() {
let msg = RecoveryMessage::Data("Some data".into());

let address =
Address::from_str("0x014587212741268ad0b1bc727efce9711dbde69c484a9db38bd83bb1b3017c05")
.unwrap();

let signature = Signature::from_str(
"64d8b60c08a7ecab307cb11a31a7153ec7e4ff06a8fb78b4fe9c982d44c731efe63303ec5c7686a56445bacdd4ee89f592f1b3e68bded25ea404cd6806205db4"
).expect("could not parse signature");

signature.verify(msg, address).unwrap();
}

#[test]
fn recover_signature() {
let signature = Signature::from_str(
"64d8b60c08a7ecab307cb11a31a7153ec7e4ff06a8fb78b4fe9c982d44c731efe63303ec5c7686a56445bacdd4ee89f592f1b3e68bded25ea404cd6806205db4"
).expect("could not parse signature");

assert_eq!(
signature.recover("Some data").unwrap(),
Address::from_str("0x014587212741268ad0b1bc727efce9711dbde69c484a9db38bd83bb1b3017c05")
.unwrap()
);
}

#[test]
fn signature_from_str() {
let s1 = Signature::from_str(
"0x64d8b60c08a7ecab307cb11a31a7153ec7e4ff06a8fb78b4fe9c982d44c731efe63303ec5c7686a56445bacdd4ee89f592f1b3e68bded25ea404cd6806205db4"
).expect("could not parse 0x-prefixed signature");

let s2 = Signature::from_str(
"64d8b60c08a7ecab307cb11a31a7153ec7e4ff06a8fb78b4fe9c982d44c731efe63303ec5c7686a56445bacdd4ee89f592f1b3e68bded25ea404cd6806205db4"
).expect("could not parse non-prefixed signature");

assert_eq!(s1, s2);
}
}
4 changes: 2 additions & 2 deletions fuels-rs/src/code_gen/abigen.rs
Expand Up @@ -69,11 +69,11 @@ impl Abigen {
}

/// Entry point of the Abigen's expansion logic.
/// The high-level goal of this function is to expand[0] a contract
/// The high-level goal of this function is to expand* a contract
/// defined as a JSON into type-safe bindings of that contract that can be
/// used after it is brought into scope after a successful generation.
///
/// [0]: To expand, in procedural macro terms, means to automatically generate
/// *: To expand, in procedural macro terms, means to automatically generate
/// Rust code after a transformation of `TokenStream` to another
/// set of `TokenStream`. This generated Rust code is the brought into scope
/// after it is called through a procedural macro (`abigen!()` in our case).
Expand Down
2 changes: 2 additions & 0 deletions fuels-rs/src/code_gen/functions_gen.rs
Expand Up @@ -25,6 +25,8 @@ use std::collections::HashMap;
/// The actual logic inside the function is the function `method_hash` under
/// [`Contract`], which is responsible for encoding the function selector
/// and the function parameters that will be used in the actual contract call.
///
/// [`Contract`]: crate::contract::Contract
pub fn expand_function(
function: &Function,
abi_parser: &ABIParser,
Expand Down
27 changes: 27 additions & 0 deletions fuels-signers/Cargo.toml
@@ -0,0 +1,27 @@
[package]
name = "fuels-signers"
version = "0.1.2"
authors = ["Fuel Labs <contact@fuel.sh>"]
edition = "2021"
homepage = "https://fuel.network/"
license = "Apache-2.0"
repository = "https://github.com/FuelLabs/fuels-rs"
description = "Fuel Rust SDK signers."

[dependencies]
async-trait = { version = "0.1.50", default-features = false }
bytes = { version = "1.1.0", features = ["serde"] }
fuel-tx = "0.1"
fuel-types = { version = "0.1", default-features = false }
fuel-vm = "0.1"
fuels-core = { version = "0.1.2", path = "../fuels-core" }
hex = { version = "0.4.3", default-features = false, features = ["std"] }
rand = { version = "0.8.4", default-features = false }
secp256k1 = { version = "0.20", features = ["recovery"] }
serde = { version = "1.0.124", default-features = true, features = ["derive"] }
sha2 = { version = "0.9.8", default-features = false }
thiserror = { version = "1.0.30", default-features = false }
tokio = { version = "1.10.1", features = ["full"] }

[dev-dependencies]
hex = { version = "0.4.3", default-features = false, features = ["std"] }
69 changes: 69 additions & 0 deletions fuels-signers/src/lib.rs
@@ -0,0 +1,69 @@
mod wallet;

pub use wallet::Wallet;

use async_trait::async_trait;
use fuel_tx::Transaction;
use fuel_types::Address;
use fuels_core::signature::Signature;
use std::error::Error;

/// A wallet instantiated with a locally stored private key
pub type LocalWallet = Wallet;

/// Trait for signing transactions and messages
///
/// Implement this trait to support different signing modes, e.g. Ledger, hosted etc.
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait Signer: std::fmt::Debug + Send + Sync {
type Error: Error + Send + Sync;
/// Signs the hash of the provided message
async fn sign_message<S: Send + Sync + AsRef<[u8]>>(
&self,
message: S,
) -> Result<Signature, Self::Error>;

/// Signs the transaction
async fn sign_transaction(&self, message: &Transaction) -> Result<Signature, Self::Error>;

/// Returns the signer's Fuel Address
fn address(&self) -> Address;
}

#[cfg(test)]
mod tests {
use std::str::FromStr;

use rand::{rngs::StdRng, RngCore, SeedableRng};
use secp256k1::SecretKey;

use super::*;

#[tokio::test]
async fn sign_and_verify() {
let mut rng = StdRng::seed_from_u64(2322u64);
let mut secret_seed = [0u8; 32];
rng.fill_bytes(&mut secret_seed);

let secret =
SecretKey::from_slice(&secret_seed).expect("Failed to generate random secret!");

let wallet = LocalWallet::new_from_private_key(secret).unwrap();

let message = "my message";

let signature = wallet.sign_message(message.as_bytes()).await.unwrap();

// Check if signature is what we expect it to be
assert_eq!(signature.compact, Signature::from_str("0x8eeb238db1adea4152644f1cd827b552dfa9ab3f4939718bb45ca476d167c6512a656f4d4c7356bfb9561b14448c230c6e7e4bd781df5ee9e5999faa6495163d").unwrap().compact);

// Recover address that signed the message
let recovered_address = signature.recover(message).unwrap();

assert_eq!(wallet.address, recovered_address);

// Verify signature
signature.verify(message, recovered_address).unwrap();
}
}

0 comments on commit 40d9b6b

Please sign in to comment.