Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API to Account for mnemonic seed phrases #87

Merged
merged 23 commits into from
Mar 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b0d4509
feat: Derive account from mneumonic phrase
fubuloubu Feb 1, 2020
f659bb2
test: Add hdaccount test from MetaMask
fubuloubu Feb 1, 2020
7db579c
feat: Allow passphrase
fubuloubu Feb 1, 2020
4759544
bug: Do proper check of seed words before deriving key
fubuloubu Feb 1, 2020
dfb8286
feat: Added generation with mnemonic
fubuloubu Feb 2, 2020
15f7335
test: Added test from ganache-core
fubuloubu Feb 2, 2020
685ea08
doc: Added docstrings to new API methods + doctests
fubuloubu Feb 11, 2020
ecd5126
test: Added tests for bad seeds
fubuloubu Feb 11, 2020
99b7165
feat: Extra validation by expanding the keywords
fubuloubu Feb 13, 2020
67478b1
refactor: Changed how API works for generating mnemonic
fubuloubu Feb 14, 2020
780a7cf
test: Added integration test for mnemonics from ethers.js
fubuloubu Feb 15, 2020
2bad34b
feat: Add ability to specify language when generating w/ mnemonic
fubuloubu Feb 15, 2020
acd6f5f
refactor: Use ValidationError, and ensure exception message is thrown
fubuloubu Feb 15, 2020
042635c
feat: Add ability to derive multiple addresses
fubuloubu Feb 15, 2020
aa20674
refactor: Disable new API unless opted in
fubuloubu Feb 17, 2020
65afded
doc: Clean up explanation of derive_child_key
fubuloubu Mar 6, 2020
8bff4bc
refactor: Remove generate list of accounts
fubuloubu Mar 9, 2020
1d1bb68
test: Added more tests for poor use of API
fubuloubu Mar 9, 2020
113414f
review: Add type hints
fubuloubu Mar 17, 2020
fdd657b
review: Use Permalinks
fubuloubu Mar 17, 2020
626f18c
review: Typo
fubuloubu Mar 17, 2020
46958ce
bug: Iterate over normalized string
fubuloubu Mar 19, 2020
94257b5
Add note to docs about experimental hdaccount features
kclowes Mar 20, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved

@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
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved

>>> 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:
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
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",
),
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
])
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")
fubuloubu marked this conversation as resolved.
Show resolved Hide resolved
Loading