feat: mnemonic phrase support for wallet (#256)

* feat: mnemonic phrase support for wallet

* refactor: better error handling and clippy linting

* fix: derive from path and tests

* chore: renamed package coins-bip39

* refactor: convenient builder API to setup mnemonic wallet

* refactor: re-export coins-bip39 for convenience

* clippy: fix warnings for multiple complex types in provider

* feat: randomly generated mnemonic phrase can be written to storage
This commit is contained in:
Rohit Narurkar 2021-04-05 13:14:58 +05:30 committed by GitHub
parent 32b4e9e3f5
commit 79862ffda5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 846 additions and 278 deletions

724
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,9 @@ pub use transaction::{Transaction, TransactionReceipt, TransactionRequest};
mod address_or_bytes;
pub use address_or_bytes::AddressOrBytes;
mod path_or_string;
pub use path_or_string::PathOrString;
mod i256;
pub use i256::I256;

View File

@ -0,0 +1,37 @@
use std::path::{Path, PathBuf};
/// A type that can either be a `Path` or a `String`
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PathOrString {
/// A path type
Path(PathBuf),
/// A string type
String(String),
}
impl From<PathBuf> for PathOrString {
fn from(p: PathBuf) -> Self {
PathOrString::Path(p)
}
}
impl From<&str> for PathOrString {
fn from(s: &str) -> Self {
let path = Path::new(s);
if path.exists() {
PathOrString::Path(path.to_owned())
} else {
PathOrString::String(s.to_owned())
}
}
}
impl PathOrString {
/// Reads the contents at path, or simply returns the string.
pub fn read(&self) -> Result<String, std::io::Error> {
match self {
PathOrString::Path(pathbuf) => std::fs::read_to_string(pathbuf),
PathOrString::String(s) => Ok(s.to_string()),
}
}
}

View File

@ -1,5 +1,6 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(broken_intra_doc_links)]
#![allow(clippy::type_complexity)]
//! # Clients for interacting with Ethereum nodes
//!
//! This crate provides asynchronous [Ethereum JSON-RPC](https://github.com/ethereum/wiki/wiki/JSON-RPC)

View File

@ -16,7 +16,8 @@ rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
ethers-core = { version = "0.2.2", path = "../ethers-core" }
thiserror = { version = "1.0.24", default-features = false }
coins-bip32 = "0.2.2"
coins-bip39 = "0.2.2"
coins-ledger = { version = "0.1.0", default-features = false, optional = true }
eth-keystore = { version = "0.2.0" }
hex = { version = "0.4.3", default-features = false, features = ["std"] }

View File

@ -39,7 +39,10 @@
//! [`Transaction`]: ethers_core::types::Transaction
//! [`TransactionRequest`]: ethers_core::types::TransactionRequest
mod wallet;
pub use wallet::Wallet;
pub use wallet::{MnemonicBuilder, Wallet, WalletError};
/// Re-export the BIP-32 crate so that wordlists can be accessed conveniently.
pub use coins_bip39;
/// A wallet instantiated with a locally stored private key
pub type LocalWallet = Wallet<ethers_core::k256::ecdsa::SigningKey>;

View File

@ -0,0 +1,279 @@
//! Specific helper functions for creating/loading a mnemonic private key following BIP-39
//! specifications
use crate::{wallet::util::key_to_address, Wallet, WalletError};
use coins_bip32::path::DerivationPath;
use coins_bip39::{Mnemonic, Wordlist};
use ethers_core::{k256::ecdsa::SigningKey, types::PathOrString, utils::to_checksum};
use rand::Rng;
use std::{fs::File, io::Write, marker::PhantomData, path::PathBuf, str::FromStr};
use thiserror::Error;
const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/60'/0'/0/";
/// Represents a structure that can resolve into a `Wallet<SigningKey>`.
#[derive(Clone, Debug, PartialEq)]
pub struct MnemonicBuilder<W: Wordlist> {
/// The mnemonic phrase can be supplied to the builder as a string or a path to the file whose
/// contents are the phrase. A builder that has a valid phrase should `build` the wallet.
phrase: Option<PathOrString>,
/// The mnemonic builder can also be asked to generate a new random wallet by providing the
/// number of words in the phrase. By default this is set to 12.
word_count: usize,
/// The derivation path at which the extended private key child will be derived at. By default
/// the mnemonic builder uses the path: "m/44'/60'/0'/0/0".
derivation_path: DerivationPath,
/// Optional password for the mnemonic phrase.
password: Option<String>,
/// Optional field that if enabled, writes the mnemonic phrase to disk storage at the provided
/// path.
write_to: Option<PathBuf>,
/// PhantomData
_wordlist: PhantomData<W>,
}
/// Error produced by the mnemonic wallet module
#[derive(Error, Debug)]
pub enum MnemonicBuilderError {
/// Error suggests that a phrase (path or words) was expected but not found
#[error("Expected phrase not found")]
ExpectedPhraseNotFound,
/// Error suggests that a phrase (path or words) was not expected but found
#[error("Unexpected phrase found")]
UnexpectedPhraseFound,
}
impl<W: Wordlist> Default for MnemonicBuilder<W> {
fn default() -> Self {
Self {
phrase: None,
word_count: 12usize,
derivation_path: DerivationPath::from_str(&format!(
"{}{}",
DEFAULT_DERIVATION_PATH_PREFIX, 0
))
.expect("should parse the default derivation path"),
password: None,
write_to: None,
_wordlist: PhantomData,
}
}
}
impl<W: Wordlist> MnemonicBuilder<W> {
/// Sets the phrase in the mnemonic builder. The phrase can either be a string or a path to
/// the file that contains the phrase. Once a phrase is provided, the key will be generated
/// deterministically by calling the `build` method.
///
/// # Example
///
/// ```
/// use ethers_signers::{MnemonicBuilder, coins_bip39::English};
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
///
/// let wallet = MnemonicBuilder::<English>::default()
/// .phrase("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
/// .build()?;
///
/// # Ok(())
/// # }
/// ```
pub fn phrase<P: Into<PathOrString>>(mut self, phrase: P) -> Self {
self.phrase = Some(phrase.into());
self
}
/// Sets the word count of a mnemonic phrase to be generated at random. If the `phrase` field
/// is set, then `word_count` will be ignored.
///
/// # Example
///
/// ```
/// use ethers_signers::{MnemonicBuilder, coins_bip39::English};
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
///
/// let mut rng = rand::thread_rng();
/// let wallet = MnemonicBuilder::<English>::default()
/// .word_count(24)
/// .build_random(&mut rng)?;
///
/// # Ok(())
/// # }
/// ```
pub fn word_count(mut self, count: usize) -> Self {
self.word_count = count;
self
}
/// Sets the derivation path of the child key to be derived. The derivation path is calculated
/// using the default derivation path prefix used in Ethereum, i.e. "m/44'/60'/0'/0/{index}".
pub fn index<U: Into<u32>>(mut self, index: U) -> Result<Self, WalletError> {
self.derivation_path = DerivationPath::from_str(&format!(
"{}{}",
DEFAULT_DERIVATION_PATH_PREFIX,
index.into()
))?;
Ok(self)
}
/// Sets the derivation path of the child key to be derived.
pub fn derivation_path(mut self, path: &str) -> Result<Self, WalletError> {
self.derivation_path = DerivationPath::from_str(path)?;
Ok(self)
}
/// Sets the password used to construct the seed from the mnemonic phrase.
pub fn password(mut self, password: &str) -> Self {
self.password = Some(password.to_string());
self
}
/// Sets the path to which the randomly generated phrase will be written to. This field is
/// ignored when building a wallet from the provided mnemonic phrase.
pub fn write_to<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.write_to = Some(path.into());
self
}
/// Builds a `LocalWallet` using the parameters set in mnemonic builder. This method expects
/// the phrase field to be set.
pub fn build(&self) -> Result<Wallet<SigningKey>, WalletError> {
let mnemonic = match &self.phrase {
Some(path_or_string) => {
let phrase = path_or_string.read()?;
Mnemonic::<W>::new_from_phrase(&phrase)?
}
None => return Err(MnemonicBuilderError::ExpectedPhraseNotFound.into()),
};
self.mnemonic_to_wallet(&mnemonic)
}
/// Builds a `LocalWallet` using the parameters set in the mnemonic builder and constructing
/// the phrase using the provided random number generator.
pub fn build_random<R: Rng>(&self, rng: &mut R) -> Result<Wallet<SigningKey>, WalletError> {
let mnemonic = match &self.phrase {
None => Mnemonic::<W>::new_with_count(rng, self.word_count)?,
_ => return Err(MnemonicBuilderError::UnexpectedPhraseFound.into()),
};
let wallet = self.mnemonic_to_wallet(&mnemonic)?;
// Write the mnemonic phrase to storage if a directory has been provided.
if let Some(dir) = &self.write_to {
let mut file = File::create(dir.as_path().join(to_checksum(&wallet.address, None)))?;
file.write_all(mnemonic.to_phrase()?.as_bytes())?;
}
Ok(wallet)
}
fn mnemonic_to_wallet(
&self,
mnemonic: &Mnemonic<W>,
) -> Result<Wallet<SigningKey>, WalletError> {
let derived_priv_key =
mnemonic.derive_key(&self.derivation_path, self.password.as_deref())?;
let key: &SigningKey = derived_priv_key.as_ref();
let signer = SigningKey::from_bytes(&key.to_bytes())?;
let address = key_to_address(&signer);
Ok(Wallet::<SigningKey> {
signer,
address,
chain_id: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coins_bip39::English;
use tempfile::tempdir;
const TEST_DERIVATION_PATH: &str = "m/44'/60'/0'/2/1";
#[tokio::test]
async fn mnemonic_deterministic() {
// Testcases have been taken from MyCryptoWallet
const TESTCASES: [(&str, u32, Option<&str>, &str); 4] = [
(
"work man father plunge mystery proud hollow address reunion sauce theory bonus",
0u32,
Some("TREZOR123"),
"0x431a00DA1D54c281AeF638A73121B3D153e0b0F6",
),
(
"inject danger program federal spice bitter term garbage coyote breeze thought funny",
1u32,
Some("LEDGER321"),
"0x231a3D0a05d13FAf93078C779FeeD3752ea1350C",
),
(
"fire evolve buddy tenant talent favorite ankle stem regret myth dream fresh",
2u32,
None,
"0x1D86AD5eBb2380dAdEAF52f61f4F428C485460E9",
),
(
"thumb soda tape crunch maple fresh imitate cancel order blind denial giraffe",
3u32,
None,
"0xFB78b25f69A8e941036fEE2A5EeAf349D81D4ccc",
),
];
TESTCASES
.iter()
.for_each(|(phrase, index, password, expected_addr)| {
let wallet = match password {
Some(psswd) => MnemonicBuilder::<English>::default()
.phrase(*phrase)
.index(*index)
.unwrap()
.password(psswd)
.build()
.unwrap(),
None => MnemonicBuilder::<English>::default()
.phrase(*phrase)
.index(*index)
.unwrap()
.build()
.unwrap(),
};
assert_eq!(&to_checksum(&wallet.address, None), expected_addr);
})
}
#[tokio::test]
async fn mnemonic_write_read() {
let dir = tempdir().unwrap();
// Construct a wallet from random mnemonic phrase and write it to the temp dir.
let mut rng = rand::thread_rng();
let wallet1 = MnemonicBuilder::<English>::default()
.word_count(24)
.derivation_path(TEST_DERIVATION_PATH)
.unwrap()
.write_to(dir.as_ref())
.build_random(&mut rng)
.unwrap();
// Ensure that only one file has been created.
let paths = std::fs::read_dir(dir.as_ref()).unwrap();
assert_eq!(paths.count(), 1);
// Use the newly created file's path to instantiate wallet.
let phrase_path = dir.as_ref().join(to_checksum(&wallet1.address, None));
let wallet2 = MnemonicBuilder::<English>::default()
.phrase(phrase_path.to_str().unwrap())
.derivation_path(TEST_DERIVATION_PATH)
.unwrap()
.build()
.unwrap();
// Ensure that both wallets belong to the same address.
assert_eq!(wallet1.address, wallet2.address);
dir.close().unwrap();
}
}

View File

@ -1,5 +1,12 @@
mod hash;
mod mnemonic;
pub use mnemonic::{MnemonicBuilder, MnemonicBuilderError};
mod private_key;
pub use private_key::WalletError;
mod util;
#[cfg(feature = "yubihsm")]
mod yubi;

View File

@ -1,14 +1,13 @@
//! Specific helper functions for loading an offline K256 Private Key stored on disk
use super::Wallet;
use crate::wallet::{mnemonic::MnemonicBuilderError, util::key_to_address};
use coins_bip32::Bip32Error;
use coins_bip39::MnemonicError;
use eth_keystore::KeystoreError;
use ethers_core::{
k256::{
ecdsa::SigningKey, elliptic_curve::error::Error as K256Error, EncodedPoint as K256PublicKey,
},
k256::ecdsa::{self, SigningKey},
rand::{CryptoRng, Rng},
types::Address,
utils::keccak256,
};
use std::{path::Path, str::FromStr};
use thiserror::Error;
@ -16,9 +15,27 @@ use thiserror::Error;
#[derive(Error, Debug)]
/// Error thrown by the Wallet module
pub enum WalletError {
/// Error propagated from the BIP-32 crate
#[error(transparent)]
Bip32Error(#[from] Bip32Error),
/// Error propagated from the BIP-39 crate
#[error(transparent)]
Bip39Error(#[from] MnemonicError),
/// Underlying eth keystore error
#[error(transparent)]
EthKeystoreError(#[from] KeystoreError),
/// Error propagated from k256's ECDSA module
#[error(transparent)]
EcdsaError(#[from] ecdsa::Error),
/// Error propagated from the hex crate.
#[error(transparent)]
HexError(#[from] hex::FromHexError),
/// Error propagated by IO operations
#[error(transparent)]
IoError(#[from] std::io::Error),
/// Error propagated from the mnemonic builder module.
#[error(transparent)]
MnemonicBuilderError(#[from] MnemonicBuilderError),
}
impl Clone for Wallet<SigningKey> {
@ -33,8 +50,6 @@ impl Clone for Wallet<SigningKey> {
}
impl Wallet<SigningKey> {
// TODO: Add support for mnemonic
/// Creates a new random encrypted JSON with the provided password and stores it in the
/// provided directory
pub fn new_keystore<P, R, S>(dir: P, rng: &mut R, password: S) -> Result<Self, WalletError>
@ -44,8 +59,7 @@ impl Wallet<SigningKey> {
S: AsRef<[u8]>,
{
let (secret, _) = eth_keystore::new(dir, rng, password)?;
let signer = SigningKey::from_bytes(secret.as_slice())
.expect("private key should always be convertible to signing key");
let signer = SigningKey::from_bytes(secret.as_slice())?;
let address = key_to_address(&signer);
Ok(Self {
signer,
@ -61,8 +75,7 @@ impl Wallet<SigningKey> {
S: AsRef<[u8]>,
{
let secret = eth_keystore::decrypt_key(keypath, password)?;
let signer = SigningKey::from_bytes(secret.as_slice())
.expect("private key should always be convertible to signing key");
let signer = SigningKey::from_bytes(secret.as_slice())?;
let address = key_to_address(&signer);
Ok(Self {
signer,
@ -83,15 +96,6 @@ impl Wallet<SigningKey> {
}
}
fn key_to_address(secret_key: &SigningKey) -> Address {
// TODO: Can we do this in a better way?
let uncompressed_pub_key = K256PublicKey::from(&secret_key.verify_key()).decompress();
let public_key = uncompressed_pub_key.unwrap().to_bytes();
debug_assert_eq!(public_key[0], 0x04);
let hash = keccak256(&public_key[1..]);
Address::from_slice(&hash[12..])
}
impl PartialEq for Wallet<SigningKey> {
fn eq(&self, other: &Self) -> bool {
self.signer.to_bytes().eq(&other.signer.to_bytes())
@ -129,11 +133,11 @@ impl From<K256SecretKey> for Wallet<SigningKey> {
}
impl FromStr for Wallet<SigningKey> {
type Err = K256Error;
type Err = WalletError;
fn from_str(src: &str) -> Result<Self, Self::Err> {
let src = hex::decode(src).expect("invalid hex when reading PrivateKey");
let sk = SigningKey::from_bytes(&src).unwrap(); // TODO
let src = hex::decode(src)?;
let sk = SigningKey::from_bytes(&src)?;
Ok(sk.into())
}
}
@ -142,6 +146,7 @@ impl FromStr for Wallet<SigningKey> {
mod tests {
use super::*;
use crate::Signer;
use ethers_core::types::Address;
use std::fs;
use tempfile::tempdir;

View File

@ -0,0 +1,14 @@
use ethers_core::{
k256::{ecdsa::SigningKey, EncodedPoint as K256PublicKey},
types::Address,
utils::keccak256,
};
pub fn key_to_address(secret_key: &SigningKey) -> Address {
// TODO: Can we do this in a better way?
let uncompressed_pub_key = K256PublicKey::from(&secret_key.verify_key()).decompress();
let public_key = uncompressed_pub_key.unwrap().to_bytes();
debug_assert_eq!(public_key[0], 0x04);
let hash = keccak256(&public_key[1..]);
Address::from_slice(&hash[12..])
}