Skip to content

Commit

Permalink
Merge pull request #87 from fubuloubu/hdaccount
Browse files Browse the repository at this point in the history
Add API to Account for mnemonic seed phrases
  • Loading branch information
kclowes authored Mar 20, 2020
2 parents affc4c8 + 94257b5 commit 9459ec6
Show file tree
Hide file tree
Showing 15 changed files with 1,482 additions and 16 deletions.
61 changes: 61 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,46 @@ common: &common
- ./eggs
key: cache-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}-{{ checksum "tox.ini" }}

integration: &integration
working_directory: ~/repo
steps:
- checkout
- run:
name: merge pull request base
command: ./.circleci/merge_pr.sh
- run:
name: merge pull request base (2nd try)
command: ./.circleci/merge_pr.sh
when: on_fail
- run:
name: merge pull request base (3rd try)
command: ./.circleci/merge_pr.sh
when: on_fail
- restore_cache:
keys:
- cache-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}-{{ checksum "tox.ini" }}
- run:
name: install dependencies (python)
command: pip install --user tox
- run:
name: install dependencies (node)
command: sudo -E bash tests/integration/ethers-cli/setup_node_v12.sh && sudo apt-get install -y nodejs
- run:
name: Build ethers-cli
command: cd tests/integration/ethers-cli && sudo npm install -g . && cd ../../../
- run:
name: run tox
command: ~/.local/bin/tox -r
- save_cache:
paths:
- .hypothesis
- .tox
- ~/.cache/pip
- ~/.local
- ./eggs
- tests/integration/ethers-cli/node_modules
key: cache-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}-{{ checksum "tox.ini" }}

jobs:
doctest:
<<: *common
Expand Down Expand Up @@ -66,6 +106,24 @@ jobs:
- image: circleci/python:3.8
environment:
TOXENV: py38-core
py36-integration:
<<: *integration
docker:
- image: circleci/python:3.6
environment:
TOXENV: py36-integration
py37-integration:
<<: *integration
docker:
- image: circleci/python:3.7
environment:
TOXENV: py37-integration
py38-integration:
<<: *integration
docker:
- image: circleci/python:3.8
environment:
TOXENV: py38-integration
workflows:
version: 2
test:
Expand All @@ -75,3 +133,6 @@ workflows:
- py36-core
- py37-core
- py38-core
- py36-integration
- py37-integration
- py38-integration
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ virtualenv -p python3 venv
pip install -e .[dev]
```

To run the integration test cases, you need to install node and the custom cli tool as follows:

```sh
apt-get install -y nodejs # As sudo
./tests/integration/ethers-cli/setup_node_v12.sh # As sudo
cd tests/integration/ethers-cli
npm install -g . # As sudo
```

### Testing Setup

During development, you might like to have tests run on every file save.
Expand Down
103 changes: 103 additions & 0 deletions eth_account/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
from eth_account.datastructures import (
AttributeDict,
)
from eth_account.hdaccount import (
ETHEREUM_DEFAULT_PATH,
generate_mnemonic,
key_from_seed,
seed_from_mnemonic,
)
from eth_account.messages import (
SignableMessage,
_hash_eip191_message,
Expand All @@ -65,6 +71,16 @@ class Account(object):

_default_kdf = os.getenv('ETH_ACCOUNT_KDF', 'scrypt')

# Enable unaudited features (off by default)
_use_unaudited_hdwallet_features = False

@classmethod
def enable_unaudited_hdwallet_features(cls):
"""
Use this flag to enable unaudited HD Wallet features.
"""
cls._use_unaudited_hdwallet_features = True

@combomethod
def create(self, extra_entropy=''):
r"""
Expand Down Expand Up @@ -229,6 +245,93 @@ def from_key(self, private_key):
key = self._parsePrivateKey(private_key)
return LocalAccount(key, self)

@combomethod
def from_mnemonic(self,
mnemonic: str,
passphrase: str="",
account_path: str=ETHEREUM_DEFAULT_PATH):
"""
.. CAUTION:: This feature is experimental, unaudited, and likely to change soon
:param str mnemonic: space-separated list of BIP39 mnemonic seed words
:param str passphrase: Optional passphrase used to encrypt the mnemonic
:param str account_path: Specify an alternate HD path for deriving the seed using
BIP32 HD wallet key derivation.
:return: object with methods for signing and encrypting
:rtype: LocalAccount
.. code-block:: python
>>> from eth_account import Account
>>> Account.enable_unaudited_hdwallet_features()
>>> acct = Account.from_mnemonic(
"coral allow abandon recipe top tray caught video climb similar prepare bracket "
"antenna rubber announce gauge volume hub hood burden skill immense add acid")
>>> acct.address
'0x9AdA5dAD14d925f4df1378409731a9B71Bc8569d'
# These methods are also available: sign_message(), sign_transaction(), encrypt()
# They correspond to the same-named methods in Account.*
# but without the private key argument
"""
if not self._use_unaudited_hdwallet_features:
raise AttributeError(
"The use of the Mnemonic features of Account is disabled by default until "
"its API stabilizes. To use these features, please enable them by running "
"`Account.enable_unaudited_hdwallet_features()` and try again."
)
seed = seed_from_mnemonic(mnemonic, passphrase)
private_key = key_from_seed(seed, account_path)
key = self._parsePrivateKey(private_key)
return LocalAccount(key, self)

@combomethod
def create_with_mnemonic(self,
passphrase: str="",
num_words: int=12,
language: str="english",
account_path: str=ETHEREUM_DEFAULT_PATH):
r"""
.. CAUTION:: This feature is experimental, unaudited, and likely to change soon
Creates a new private key, and returns it as a :class:`~eth_account.local.LocalAccount`,
alongside the mnemonic that can used to regenerate it using any BIP39-compatible wallet.
:param str passphrase: Extra passphrase to encrypt the seed phrase
:param int num_words: Number of words to use with seed phrase. Default is 12 words.
Must be one of [12, 15, 18, 21, 24].
:param str language: Language to use for BIP39 mnemonic seed phrase.
:param str account_path: Specify an alternate HD path for deriving the seed using
BIP32 HD wallet key derivation.
:returns: A tuple consisting of an object with private key and convenience methods,
and the mnemonic seed phrase that can be used to restore the account.
:rtype: (LocalAccount, str)
.. code-block:: python
>>> from eth_account import Account
>>> Account.enable_unaudited_hdwallet_features()
>>> acct, mnemonic = Account.create_with_mnemonic()
>>> acct.address
'0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E'
>>> acct == Account.from_mnemonic(mnemonic)
True
# These methods are also available: sign_message(), sign_transaction(), encrypt()
# They correspond to the same-named methods in Account.*
# but without the private key argument
"""
if not self._use_unaudited_hdwallet_features:
raise AttributeError(
"The use of the Mnemonic features of Account is disabled by default until "
"its API stabilizes. To use these features, please enable them by running "
"`Account.enable_unaudited_hdwallet_features()` and try again."
)
mnemonic = generate_mnemonic(num_words, language)
return self.from_mnemonic(mnemonic, passphrase, account_path), mnemonic

@combomethod
def recover_message(self, signable_message: SignableMessage, vrs=None, signature=None):
r"""
Expand Down
30 changes: 30 additions & 0 deletions eth_account/hdaccount/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from eth_utils import (
ValidationError,
)

from .deterministic import (
HDPath,
)
from .mnemonic import (
Mnemonic,
)

ETHEREUM_DEFAULT_PATH = "m/44'/60'/0'/0/0"


def generate_mnemonic(num_words: int, lang: str) -> str:
return Mnemonic(lang).generate(num_words)


def seed_from_mnemonic(words: str, passphrase: str) -> bytes:
lang = Mnemonic.detect_language(words)
expanded_words = Mnemonic(lang).expand(words)
if not Mnemonic(lang).is_mnemonic_valid(expanded_words):
raise ValidationError(
f"Provided words: '{expanded_words}', are not a valid BIP39 mnemonic phrase!"
)
return Mnemonic.to_seed(expanded_words, passphrase)


def key_from_seed(seed: bytes, account_path: str):
return HDPath(account_path).derive(seed)
24 changes: 12 additions & 12 deletions eth_account/hdaccount/deterministic.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,18 @@ def derive_child_key(
The function CKDpriv((k_par, c_par), i) → (k_i, c_i) computes a child extended
private key from the parent extended private key:
- Check whether i ≥ 2**31 (whether the child is a hardened key).
- If so (hardened child):
let I = HMAC-SHA512(Key = c_par, Data = 0x00 || ser_256(k_par) || ser_32(i)).
(Note: The 0x00 pads the private key to make it 33 bytes long.)
- If not (normal child):
let I = HMAC-SHA512(Key = c_par, Data = ser_P(point(k_par)) || ser_32(i)).
- Split I into two 32-byte sequences, I_L and I_R.
- The returned child key k_i is parse_256(I_L) + k_par (mod n).
- The returned chain code c_i is I_R.
- In case parse_256(I_L) ≥ n or k_i = 0, the resulting key is invalid,
and one should proceed with the next value for i.
(Note: this has probability lower than 1 in 2**127.)
1. Check whether the child is a hardened key (i ≥ 2**31). If the child is a hardened key,
let I = HMAC-SHA512(Key = c_par, Data = 0x00 || ser_256(k_par) || ser_32(i)).
(Note: The 0x00 pads the private key to make it 33 bytes long.)
If it is not a hardened key, then
let I = HMAC-SHA512(Key = c_par, Data = ser_P(point(k_par)) || ser_32(i)).
2. Split I into two 32-byte sequences, I_L and I_R.
3. The returned child key k_i is parse_256(I_L) + k_par (mod n).
4. The returned chain code c_i is I_R.
5. In case parse_256(I_L) ≥ n or k_i = 0, the resulting key is invalid,
and one should proceed with the next value for i.
(Note: this has probability lower than 1 in 2**127.)
"""
assert len(parent_chain_code) == 32
if isinstance(node, HardNode):
Expand Down
4 changes: 3 additions & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
[pytest]
addopts= -v --showlocals --doctest-modules --durations 10
addopts= -v --showlocals --doctest-modules --durations 10 --ignore tests/integration/ethers-cli
python_paths= .
xfail_strict=true
markers =
compatibility: mark a test to be run during compatibility fuzz testing

[pytest-watch]
runner= pytest --failed-first --maxfail=1
94 changes: 94 additions & 0 deletions tests/core/test_hdaccount.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import pytest

from eth_utils import (
ValidationError,
)

from eth_account import (
Account,
)
from eth_account.hdaccount import (
ETHEREUM_DEFAULT_PATH,
)

Account.enable_unaudited_hdwallet_features()


@pytest.mark.parametrize("mnemonic,account_path,expected_address", [
# Ganache
# https://github.com/trufflesuite/ganache-core/blob/d1cb5318cb3c694743f86f29d74/test/accounts.js
(
"into trim cross then helmet popular suit hammer cart shrug oval student",
ETHEREUM_DEFAULT_PATH,
"0x604a95C9165Bc95aE016a5299dd7d400dDDBEa9A",
),
# Metamask
# https://github.com/MetaMask/eth-hd-keyring/blob/79d088e4a73624537e924b3943830526/test/index.js
(
"finish oppose decorate face calm tragic certain desk hour urge dinosaur mango",
ETHEREUM_DEFAULT_PATH,
"0x1c96099350f13D558464eC79B9bE4445AA0eF579",
),
(
"finish oppose decorate face calm tragic certain desk hour urge dinosaur mango",
"m/44'/60'/0'/0/1", # 2nd account index in path
"0x1b00AeD43a693F3a957F9FeB5cC08AFA031E37a0",
),
])
def test_account_derivation(mnemonic, account_path, expected_address):
a = Account.from_mnemonic(mnemonic, account_path=account_path)
assert a.address == expected_address


def test_account_restore():
a1, mnemonic = Account.create_with_mnemonic(num_words=24, passphrase="TESTING")
a2 = Account.from_mnemonic(mnemonic, passphrase="TESTING")
assert a1.address == a2.address


def test_bad_passphrase():
a1, mnemonic = Account.create_with_mnemonic(passphrase="My passphrase")
a2 = Account.from_mnemonic(mnemonic, passphrase="Not my passphrase")
assert a1.address != a2.address


def test_incorrect_size():
with pytest.raises(ValidationError, match="Language not detected .*"):
Account.from_mnemonic("this is not a seed phrase")


def test_malformed_seed():
with pytest.raises(ValidationError, match=".* not a valid BIP39 mnemonic phrase!"):
# Missing 12th word
Account.from_mnemonic("into trim cross then helmet popular suit hammer cart shrug oval")


def test_incorrect_checksum():
with pytest.raises(ValidationError, match=".* not a valid BIP39 mnemonic phrase!"):
# Moved 12th word of valid phrase to be 1st
Account.from_mnemonic(
"student into trim cross then helmet popular suit hammer cart shrug oval"
)


def test_incorrect_num_words():
with pytest.raises(ValidationError, match="Invalid choice for number of words.*"):
Account.create_with_mnemonic(num_words=11)


def test_bad_account_path1():
with pytest.raises(ValidationError, match="Path is not valid.*"):
Account.from_mnemonic(
"finish oppose decorate face calm tragic certain desk hour urge dinosaur mango",
account_path='not an account path'
)


def test_bad_account_path2():
with pytest.raises(ValidationError, match="Path.*is not valid.*"):
Account.create_with_mnemonic(account_path='m/not/an/account/path')


def test_unknown_language():
with pytest.raises(ValidationError, match="Invalid language choice.*"):
Account.create_with_mnemonic(language="pig latin")
Loading

0 comments on commit 9459ec6

Please sign in to comment.