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

Allow 4-character abbreviations for mnemonic when using the existing-mnemonic workflow #242

Merged
merged 7 commits into from
Mar 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions staking_deposit/cli/existing_mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from staking_deposit.exceptions import ValidationError
from staking_deposit.key_handling.key_derivation.mnemonic import (
verify_mnemonic,
reconstruct_mnemonic,
)
from staking_deposit.utils.constants import (
WORD_LISTS_PATH,
Expand All @@ -23,7 +23,8 @@


def validate_mnemonic(ctx: click.Context, param: Any, mnemonic: str) -> str:
if verify_mnemonic(mnemonic, WORD_LISTS_PATH):
mnemonic = reconstruct_mnemonic(mnemonic, WORD_LISTS_PATH)
if mnemonic is not None:
return mnemonic
else:
raise ValidationError(load_text(['err_invalid_mnemonic']))
Expand Down
4 changes: 2 additions & 2 deletions staking_deposit/cli/new_mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from staking_deposit.key_handling.key_derivation.mnemonic import (
get_mnemonic,
reconstruct_mnemonic,
)
from staking_deposit.utils.click import (
captive_prompt_callback,
Expand Down Expand Up @@ -47,15 +48,14 @@
def new_mnemonic(ctx: click.Context, mnemonic_language: str, **kwargs: Any) -> None:
mnemonic = get_mnemonic(language=mnemonic_language, words_path=WORD_LISTS_PATH)
test_mnemonic = ''
while mnemonic != test_mnemonic:
while mnemonic != reconstruct_mnemonic(test_mnemonic, WORD_LISTS_PATH):
click.clear()
click.echo(load_text(['msg_mnemonic_presentation']))
click.echo('\n\n%s\n\n' % mnemonic)
click.pause(load_text(['msg_press_any_key']))

click.clear()
test_mnemonic = click.prompt(load_text(['msg_mnemonic_retype_prompt']) + '\n\n')
test_mnemonic = test_mnemonic.lower()
click.clear()
# Do NOT use mnemonic_password.
ctx.obj = {'mnemonic': mnemonic, 'mnemonic_password': ''}
Expand Down
2 changes: 1 addition & 1 deletion staking_deposit/intl/en/cli/existing_mnemonic.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"arg_mnemonic": {
"help": "The mnemonic that you used to generate your keys. (It is recommended not to use this argument, and wait for the CLI to ask you for your mnemonic as otherwise it will appear in your shell history.)",
"prompt": "Please enter your mnemonic separated by spaces (\" \")"
"prompt": "Please enter your mnemonic separated by spaces (\" \"). Note: you only need to enter the first 4 letters of each word if you'd prefer."
},
"arg_mnemonic_password": {
"help": "This is almost certainly not the argument you are looking for: it is for mnemonic passwords, not keystore passwords. Providing a password here when you didn't use one initially, can result in lost keys (and therefore funds)! Also note that if you used this tool to generate your mnemonic initially, then you did not use a mnemonic password. However, if you are certain you used a password to \"increase\" the security of your mnemonic, this is where you enter it.",
Expand Down
2 changes: 1 addition & 1 deletion staking_deposit/intl/en/cli/new_mnemonic.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
},
"msg_mnemonic_presentation": "This is your mnemonic (seed phrase). Write it down and store it safely. It is the ONLY way to retrieve your deposit.",
"msg_press_any_key": "Press any key when you have written down your mnemonic.",
"msg_mnemonic_retype_prompt": "Please type your mnemonic (separated by spaces) to confirm you have written it down"
"msg_mnemonic_retype_prompt": "Please type your mnemonic (separated by spaces) to confirm you have written it down. Note: you only need to enter the first 4 letters of each word if you'd prefer."
}
}
42 changes: 31 additions & 11 deletions staking_deposit/key_handling/key_derivation/mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unicodedata import normalize
from secrets import randbits
from typing import (
List,
Optional,
Sequence,
)
Expand Down Expand Up @@ -68,9 +69,10 @@ def determine_mnemonic_language(mnemonic: str, words_path: str) -> Sequence[str]
languages = MNEMONIC_LANG_OPTIONS.keys()
word_language_map = {word: lang for lang in languages for word in _get_word_list(lang, words_path)}
try:
mnemonic_list = mnemonic.split(' ')
word_languages = [word_language_map[word] for word in mnemonic_list]
return list(set(word_languages))
mnemonic_list = [normalize('NFKC', word)[:4] for word in mnemonic.lower().split(' ')]
word_languages = [[lang for word, lang in word_language_map.items() if normalize('NFKC', word)[:4] == abbrev]
for abbrev in mnemonic_list]
return list(set(sum(word_languages, [])))
except KeyError:
raise ValueError('Word not found in mnemonic word lists for any language.')

Expand All @@ -90,30 +92,48 @@ def _get_checksum(entropy: bytes) -> int:
return int.from_bytes(SHA256(entropy), 'big') >> (256 - checksum_length)


def verify_mnemonic(mnemonic: str, words_path: str) -> bool:
def abbreviate_words(words: Sequence[str]) -> List[str]:
"""
Given a mnemonic, verify it against its own checksum."
Given a series of word strings, return the 4-letter version of each word (which is unique according to BIP39)
"""
return [normalize('NFKC', word)[:4] for word in words]


def reconstruct_mnemonic(mnemonic: str, words_path: str) -> Optional[str]:
"""
Given a mnemonic, a reconstructed the full version (incase the abbreviated words were used)
then verify it against its own checksum
"""
try:
languages = determine_mnemonic_language(mnemonic, words_path)
except ValueError:
return False
return None
reconstructed_mnemonic = None
for language in languages:
try:
word_list = _get_word_list(language, words_path)
mnemonic_list = mnemonic.split(' ')
word_list = abbreviate_words(_get_word_list(language, words_path))
mnemonic_list = abbreviate_words(mnemonic.lower().split(' '))
if len(mnemonic_list) not in range(12, 25, 3):
return False
return None
word_indices = [_word_to_index(word_list, word) for word in mnemonic_list]
mnemonic_int = _uint11_array_to_uint(word_indices)
checksum_length = len(mnemonic_list) // 3
checksum = mnemonic_int & 2**checksum_length - 1
entropy = (mnemonic_int - checksum) >> checksum_length
entropy_bits = entropy.to_bytes(checksum_length * 4, 'big')
return _get_checksum(entropy_bits) == checksum
full_word_list = _get_word_list(language, words_path)
if _get_checksum(entropy_bits) == checksum:
"""
This check guarantees that only one language has a valid mnemonic.
It is needed to ensure abbrivated words aren't valid in multiple languages
"""
assert reconstructed_mnemonic is None
reconstructed_mnemonic = ' '.join([_index_to_word(full_word_list, index) for index in word_indices])
else:
pass
except ValueError:
pass
return False
return reconstructed_mnemonic


def get_mnemonic(*, language: str, words_path: str, entropy: Optional[bytes]=None) -> str:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_cli/test_existing_menmonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,50 @@ async def test_script() -> None:

# Clean up
clean_key_folder(my_folder_path)


@pytest.mark.asyncio
async def test_script_abbreviated_mnemonic() -> None:
my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER')
if not os.path.exists(my_folder_path):
os.mkdir(my_folder_path)

if os.name == 'nt': # Windows
run_script_cmd = 'sh deposit.sh'
else: # Mac or Linux
run_script_cmd = './deposit.sh'

install_cmd = run_script_cmd + ' install'
proc = await asyncio.create_subprocess_shell(
install_cmd,
)
await proc.wait()

cmd_args = [
run_script_cmd,
'--language', 'english',
'--non_interactive',
'existing-mnemonic',
'--num_validators', '1',
'--mnemonic="aban aban aban aban aban aban aban aban aban aban aban abou"',
'--mnemonic-password', 'TREZOR',
'--validator_start_index', '1',
'--chain', 'mainnet',
'--keystore_password', 'MyPassword',
'--folder', my_folder_path,
]
proc = await asyncio.create_subprocess_shell(
' '.join(cmd_args),
)
await proc.wait()
# Check files
validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME)
_, _, key_files = next(os.walk(validator_keys_folder_path))

# Verify file permissions
if os.name == 'posix':
for file_name in key_files:
assert get_permissions(validator_keys_folder_path, file_name) == '0o440'

# Clean up
clean_key_folder(my_folder_path)
84 changes: 80 additions & 4 deletions tests/test_cli/test_new_mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from staking_deposit.cli import new_mnemonic
from staking_deposit.deposit import cli
from staking_deposit.key_handling.key_derivation.mnemonic import abbreviate_words
from staking_deposit.utils.constants import DEFAULT_VALIDATOR_KEYS_FOLDER_NAME, ETH1_ADDRESS_WITHDRAWAL_PREFIX
from staking_deposit.utils.intl import load_text
from .helpers import clean_key_folder, get_permissions, get_uuid
Expand All @@ -17,7 +18,7 @@
def test_new_mnemonic_bls_withdrawal(monkeypatch) -> None:
# monkeypatch get_mnemonic
def mock_get_mnemonic(language, words_path, entropy=None) -> str:
return "fakephrase"
return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"

monkeypatch.setattr(new_mnemonic, "get_mnemonic", mock_get_mnemonic)

Expand All @@ -28,7 +29,8 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
os.mkdir(my_folder_path)

runner = CliRunner()
inputs = ['english', 'english', '1', 'mainnet', 'MyPassword', 'MyPassword', 'fakephrase']
inputs = ['english', 'english', '1', 'mainnet', 'MyPassword', 'MyPassword',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about']
data = '\n'.join(inputs)
result = runner.invoke(cli, ['new-mnemonic', '--folder', my_folder_path], input=data)
assert result.exit_code == 0
Expand Down Expand Up @@ -56,7 +58,7 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
def test_new_mnemonic_eth1_address_withdrawal(monkeypatch) -> None:
# monkeypatch get_mnemonic
def mock_get_mnemonic(language, words_path, entropy=None) -> str:
return "fakephrase"
return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"

monkeypatch.setattr(new_mnemonic, "get_mnemonic", mock_get_mnemonic)

Expand All @@ -67,7 +69,8 @@ def mock_get_mnemonic(language, words_path, entropy=None) -> str:
os.mkdir(my_folder_path)

runner = CliRunner()
inputs = ['english', '1', 'mainnet', 'MyPassword', 'MyPassword', 'fakephrase']
inputs = ['english', '1', 'mainnet', 'MyPassword', 'MyPassword',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about']
data = '\n'.join(inputs)
eth1_withdrawal_address = '0x00000000219ab540356cbb839cbe05303d7705fa'
arguments = [
Expand Down Expand Up @@ -178,3 +181,76 @@ async def test_script() -> None:

# Clean up
clean_key_folder(my_folder_path)


@pytest.mark.asyncio
async def test_script_abbreviated_mnemonic() -> None:
my_folder_path = os.path.join(os.getcwd(), 'TESTING_TEMP_FOLDER')
if not os.path.exists(my_folder_path):
os.mkdir(my_folder_path)

if os.name == 'nt': # Windows
run_script_cmd = 'sh deposit.sh'
else: # Mac or Linux
run_script_cmd = './deposit.sh'

install_cmd = run_script_cmd + ' install'
proc = await asyncio.create_subprocess_shell(
install_cmd,
)
await proc.wait()

cmd_args = [
run_script_cmd,
'--language', 'english',
'--non_interactive',
'new-mnemonic',
'--num_validators', '5',
'--mnemonic_language', 'english',
'--chain', 'mainnet',
'--keystore_password', 'MyPassword',
'--folder', my_folder_path,
]
proc = await asyncio.create_subprocess_shell(
' '.join(cmd_args),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)

seed_phrase = ''
parsing = False
mnemonic_json_file = os.path.join(os.getcwd(), 'staking_deposit/../staking_deposit/cli/', 'new_mnemonic.json')
async for out in proc.stdout:
output = out.decode('utf-8').rstrip()
if output.startswith(load_text(['msg_mnemonic_presentation'], mnemonic_json_file, 'new_mnemonic')):
parsing = True
elif output.startswith(load_text(['msg_mnemonic_retype_prompt'], mnemonic_json_file, 'new_mnemonic')):
parsing = False
elif parsing:
seed_phrase += output
if len(seed_phrase) > 0:
abbreviated_mnemonic = ' '.join(abbreviate_words(seed_phrase.split(' ')))
encoded_phrase = abbreviated_mnemonic.encode()
proc.stdin.write(encoded_phrase)
proc.stdin.write(b'\n')

assert len(seed_phrase) > 0

# Check files
validator_keys_folder_path = os.path.join(my_folder_path, DEFAULT_VALIDATOR_KEYS_FOLDER_NAME)
_, _, key_files = next(os.walk(validator_keys_folder_path))

all_uuid = [
get_uuid(validator_keys_folder_path + '/' + key_file)
for key_file in key_files
if key_file.startswith('keystore')
]
assert len(set(all_uuid)) == 5

# Verify file permissions
if os.name == 'posix':
for file_name in key_files:
assert get_permissions(validator_keys_folder_path, file_name) == '0o440'

# Clean up
clean_key_folder(my_folder_path)
27 changes: 22 additions & 5 deletions tests/test_key_handling/test_key_derivation/test_mnemonic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import (
Sequence,
)
from unicodedata import normalize

from staking_deposit.utils.constants import (
MNEMONIC_LANG_OPTIONS,
Expand All @@ -13,7 +14,7 @@
_get_word_list,
get_seed,
get_mnemonic,
verify_mnemonic,
reconstruct_mnemonic,
)


Expand All @@ -40,13 +41,29 @@ def test_bip39(language: str, test: Sequence[str]) -> None:


@pytest.mark.parametrize(
'test_mnemonic,is_valid',
[(test_mnemonic[1], True)
'test_mnemonic',
[(test_mnemonic[1])
for _, language_test_vectors in test_vectors.items()
for test_mnemonic in language_test_vectors]
)
def test_verify_mnemonic(test_mnemonic: str, is_valid: bool) -> None:
assert verify_mnemonic(test_mnemonic, WORD_LISTS_PATH) == is_valid
def test_reconstruct_mnemonic(test_mnemonic: str) -> None:
assert reconstruct_mnemonic(test_mnemonic, WORD_LISTS_PATH) is not None


def abbreviate_mnemonic(mnemonic: str) -> str:
words = str.split(mnemonic)
words = [normalize('NFKC', word) for word in words]
return str.join(' ', words)


@pytest.mark.parametrize(
'test_mnemonic',
[abbreviate_mnemonic(test_mnemonic[1])
for _, language_test_vectors in test_vectors.items()
for test_mnemonic in language_test_vectors]
)
def test_reconstruct_abbreviated_mnemonic(test_mnemonic: str) -> None:
assert reconstruct_mnemonic(test_mnemonic, WORD_LISTS_PATH) is not None


@pytest.mark.parametrize(
Expand Down