Skip to content

Commit

Permalink
feat: adds account generation and import API functions (#1887)
Browse files Browse the repository at this point in the history
* feat: adds ape_accounts.generate_account for programattic account creation

* refactor: review feedback; code hygiene

* fix: missing file

* feat: warn on simple passwords

* fix: make prompt order the same as it was originally

* fix(test): handle prompt difference in CLI integration account tests

* test: use correct test account, fix passwords and output parsing

* style: doulbe spaces are for typewriters and old people

* docs: document generate_account use in accounts user guide

* feat: adds import_account_from_mnemonic and import_account_from_private_key

* fix: remove unused arg from import_account_from_private_key()

* docs: document use of import_account_from_mnemonic() and import_account_from_private_key()

* style: review feedback, docstrings and cleanup

* chore: include space in special char validator

* docs: clarity fix

Co-authored-by: antazoey <[email protected]>

* docs: use class def notation for docstring

Co-authored-by: antazoey <[email protected]>

* style(lint): line too long

* fix(docs): incorrect import in example

Co-authored-by: El De-dog-lo <[email protected]>

* docs: adds ape_accounts autodoc, improves accounts userguide additions

* docs: wording review feedback

* test: use eth_tester_provider fixture in test_default_sender_account

* docs: link to ape_accounts autodoc page from index ToC

* test: use different alias in test_import_account_from_mnemonic

* test: adds delete_account_after fixture, providing context manager that deletes account files after closing

---------

Co-authored-by: antazoey <[email protected]>
Co-authored-by: El De-dog-lo <[email protected]>
  • Loading branch information
3 people authored Jan 29, 2024
1 parent fe636bb commit 7c5b10b
Show file tree
Hide file tree
Showing 13 changed files with 486 additions and 77 deletions.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
:maxdepth: 1
methoddocs/ape.md
methoddocs/ape_accounts.md
methoddocs/api.md
methoddocs/cli.md
methoddocs/contracts.md
Expand Down
6 changes: 6 additions & 0 deletions docs/methoddocs/ape_accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# ape-accounts

```{eval-rst}
.. automodule:: ape_accounts
:members:
```
55 changes: 53 additions & 2 deletions docs/userguides/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,14 @@ Under-the-hood, this structure comes from the [eth-keyfile library](https://gith
When Ape creates the keyfile, either from import or account-generation (described below!), it prompts you for a passphrase to use for encrypting the keyfile, similarly to how you would use a password in browser-based wallets.
The keyfile stores the private key in an encrypted-at-rest state, which maximizes security of the locally-stored key material.

The `ape-accounts` plugin lets you use keyfile-based account to sign messages and transactions.
The `ape-accounts` core plugin lets you use keyfile-based account to sign messages and transactions.
When signing a message or transaction using an account from `ape-accounts`, you will be prompted to enter the passphrase you specified when importing or generating that account.

All the available CLI commands for this account's plugin can be found [here](../commands/accounts.html).
For example, you can [generate](../commands/accounts.html#accounts-generate) an account:

#### Generating New Accounts

You can [generate](../commands/accounts.html#accounts-generate) an account:

```bash
ape accounts generate <ALIAS>
Expand Down Expand Up @@ -134,6 +137,22 @@ ape accounts generate <ALIAS> --word-count <WORDCOUNT>

If you do not use the `--word-count` option, Ape will use the default word count of 12.
You can use all of these together or separately to control the way Ape creates and displays your account information.

This same functionality is also scriptable with the same inputs as the `generate` command:

```python
from ape_accounts import generate_account

account, mnemonic = generate_account("my-account", "mySecureP@ssphrase")

print(f'Save your mnemonic: {mnemonic}')
print(f'Your new account address is: {account.address}')
```

See the [documentation for `generate_account()`](../methoddocs/ape_accounts.html#ape_accounts.generate_account) for more options.

#### Importing Existing Accounts

If you already have an account and wish to import it into Ape (say, from Metamask), you can use the [import command](../commands/accounts.html#accounts-import):

```bash
Expand All @@ -158,6 +177,38 @@ ape accounts import <ALIAS> --use-mnemonic --hd-path <HDPATH>

If you use the `--hd-path` option, you will need to pass the [HDPath](https://help.myetherwallet.com/en/articles/5867305-hd-wallets-and-derivation-paths) you'd like to use as an argument in the command.
If you do not use the `--hd-path` option, Ape will use the default HDPath of (Ethereum network, first account).

You can import an account programatically using a seed phrase [using `import_account_from_mnemonic()`](../methoddocs/ape_accounts.html#ape_accounts.import_account_from_mnemonic):

```python
from ape_acounts import import_account_from_mnemonic

alias = "my-account"
passphrase = "my$ecurePassphrase"
mnemonic = "test test test test test test test test test test test junk"

account = import_account_from_mnemonic(alias, passphrase, mnemonic)

print(f'Your imported account address is: {account.address}')
```

Or using a raw private key [using `import_account_from_private_key()`](../methoddocs/ape_accounts.html#ape_accounts.import_account_from_private_key):

```python
import os
from ape_acounts import import_account_from_private_key

alias = "my-account"
passphrase = "my SecurePassphrase"
private_key = os.urandom(32).hex()

account = import_account_from_private_key(alias, passphrase, private_key)

print(f'Your imported account address is: {account.address}')
```

#### Exporting Accounts

You can also [export](../commands/accounts.html#accounts-export) the private key of an account:

```bash
Expand Down
14 changes: 2 additions & 12 deletions src/ape/cli/arguments.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,17 @@
from itertools import chain

import click
from eth_utils import is_hex

from ape.cli.choices import _ACCOUNT_TYPE_FILTER, Alias
from ape.cli.paramtype import AllFilePaths
from ape.exceptions import AccountsError, AliasAlreadyInUseError
from ape.utils.basemodel import ManagerAccessMixin
from ape.utils.validators import _validate_account_alias

_flatten = chain.from_iterable


def _alias_callback(ctx, param, value):
if value in ManagerAccessMixin.account_manager.aliases:
# Alias cannot be used.
raise AliasAlreadyInUseError(value)

elif not isinstance(value, str):
raise AccountsError(f"Alias must be a str, not '{type(value)}'.")
elif is_hex(value) and len(value) >= 42:
raise AccountsError("Longer aliases cannot be hex strings.")

return value
return _validate_account_alias(value)


def existing_alias_argument(account_type: _ACCOUNT_TYPE_FILTER = None, **kwargs):
Expand Down
49 changes: 49 additions & 0 deletions src/ape/utils/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Base non-pydantic validator utils"""
import re
from warnings import warn

from eth_utils import is_hex

from ape.exceptions import AccountsError, AliasAlreadyInUseError
from ape.utils.basemodel import ManagerAccessMixin

MIN_PASSPHRASE_LENGTH = 6


def _has_num(val: str):
return re.search(r"\d{1}", val) is not None


def _has_special(val: str):
return re.search(r"[\!@#$%\^&\*\(\) ]{1}", val) is not None


def _validate_account_alias(alias: str) -> str:
"""Validate an account alias"""
if alias in ManagerAccessMixin.account_manager.aliases:
raise AliasAlreadyInUseError(alias)
elif not isinstance(alias, str):
raise AccountsError(f"Alias must be a str, not '{type(alias)}'.")
elif is_hex(alias) and len(alias) >= 42:
# Prevents private keys from accidentally being stored in plaintext
# Ref: https://github.com/ApeWorX/ape/issues/1525
raise AccountsError("Longer aliases cannot be hex strings.")

return alias


def _validate_account_passphrase(passphrase: str) -> str:
"""Make sure given passphrase is valid for account encryption"""
if not passphrase or not isinstance(passphrase, str):
raise AccountsError("Account file encryption passphrase must be provided.")

if len(passphrase) < MIN_PASSPHRASE_LENGTH:
warn("Passphrase length is extremely short. Consider using something longer.")

if not (_has_num(passphrase) or _has_special(passphrase)):
warn("Passphrase complexity is simple. Consider using numbers and special characters.")

return passphrase


__all__ = ["_validate_account_alias", "_validate_account_passphrase"]
17 changes: 16 additions & 1 deletion src/ape_accounts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
from ape import plugins

from .accounts import AccountContainer, KeyfileAccount
from .accounts import (
AccountContainer,
KeyfileAccount,
generate_account,
import_account_from_mnemonic,
import_account_from_private_key,
)


@plugins.register(plugins.AccountPlugin)
def account_types():
return AccountContainer, KeyfileAccount


__all__ = [
"AccountContainer",
"KeyfileAccount",
"generate_account",
"import_account_from_mnemonic",
"import_account_from_private_key",
]
60 changes: 35 additions & 25 deletions src/ape_accounts/_cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import json
from typing import Optional

import click
from eth_account import Account as EthAccount
from eth_account.hdaccount import ETHEREUM_DEFAULT_PATH
from eth_utils import to_bytes, to_checksum_address
from eth_utils import to_checksum_address

from ape.cli import ape_cli_context, existing_alias_argument, non_existing_alias_argument
from ape.utils.basemodel import ManagerAccessMixin
from ape_accounts import AccountContainer, KeyfileAccount
from ape_accounts import (
AccountContainer,
KeyfileAccount,
generate_account,
import_account_from_mnemonic,
import_account_from_private_key,
)


def _get_container() -> AccountContainer:
Expand Down Expand Up @@ -88,26 +95,24 @@ def _list(cli_ctx, show_all_plugins):
@non_existing_alias_argument()
@ape_cli_context()
def generate(cli_ctx, alias, hide_mnemonic, word_count, custom_hd_path):
path = _get_container().data_folder.joinpath(f"{alias}.json")
EthAccount.enable_unaudited_hdwallet_features()
# os.urandom (used internally for this method) requires a certain amount of entropy.
# Adding entropy increases os.urandom randomness output
# Despite not being used in create_with_mnemonic
click.prompt(
"Enhance the security of your account by adding additional random input",
hide_input=True,
)
account, mnemonic = EthAccount.create_with_mnemonic(
num_words=word_count, account_path=custom_hd_path
)
if not hide_mnemonic and click.confirm("Show mnemonic?", default=True):
cli_ctx.logger.info(f"Newly generated mnemonic is: {click.style(mnemonic, bold=True)}")

show_mnemonic = not hide_mnemonic and click.confirm("Show mnemonic?", default=True)

passphrase = click.prompt(
"Create Passphrase to encrypt account",
hide_input=True,
confirmation_prompt=True,
)
path.write_text(json.dumps(EthAccount.encrypt(account.key, passphrase)))

account, mnemonic = generate_account(alias, passphrase, custom_hd_path, word_count)

if show_mnemonic:
cli_ctx.logger.info(f"Newly generated mnemonic is: {click.style(mnemonic, bold=True)}")

cli_ctx.logger.success(
f"A new account '{account.address}' with "
+ f"HDPath {custom_hd_path} has been added with the id '{alias}'"
Expand All @@ -129,31 +134,36 @@ def generate(cli_ctx, alias, hide_mnemonic, word_count, custom_hd_path):
@non_existing_alias_argument()
@ape_cli_context()
def _import(cli_ctx, alias, import_from_mnemonic, custom_hd_path):
path = _get_container().data_folder.joinpath(f"{alias}.json")
account: Optional[KeyfileAccount] = None

def ask_for_passphrase():
return click.prompt(
"Create Passphrase to encrypt account",
hide_input=True,
confirmation_prompt=True,
)

if import_from_mnemonic:
mnemonic = click.prompt("Enter mnemonic seed phrase", hide_input=True)
EthAccount.enable_unaudited_hdwallet_features()
try:
account = EthAccount.from_mnemonic(mnemonic=mnemonic, account_path=custom_hd_path)
passphrase = ask_for_passphrase()
account = import_account_from_mnemonic(alias, passphrase, mnemonic, custom_hd_path)
except Exception as error:
cli_ctx.abort(f"Seed phrase can't be imported: {error}")

else:
key = click.prompt("Enter Private Key", hide_input=True)
try:
account = EthAccount.from_key(to_bytes(hexstr=key))
passphrase = ask_for_passphrase()
account = import_account_from_private_key(alias, passphrase, key)
except Exception as error:
cli_ctx.abort(f"Key can't be imported: {error}")

passphrase = click.prompt(
"Create Passphrase to encrypt account",
hide_input=True,
confirmation_prompt=True,
)
path.write_text(json.dumps(EthAccount.encrypt(account.key, passphrase)))
cli_ctx.logger.success(
f"A new account '{account.address}' has been added with the id '{alias}'"
)
if account:
cli_ctx.logger.success(
f"A new account '{account.address}' has been added with the id '{alias}'"
)


@cli.command(short_help="Export an account private key")
Expand Down
Loading

0 comments on commit 7c5b10b

Please sign in to comment.