Repository: eth-educators/ethstaker-deposit-cli

Issue:
Certain encodings can make passwords invalid
Forward from:
- https://github.com/ethereum/staking-deposit-cli/issues/104

When using the CLI across platforms, it is possible for the user to run into a situation where a password is valid in one platform but not the other if certain special characters are used. 

There was a [pull request from Carl](https://github.com/ethereum/staking-deposit-cli/pull/120) that looks to have resolved this issue but was never landed.

Discussion:

Solution patch:
diff --git a/ethstaker_deposit/deposit.py b/ethstaker_deposit/deposit.py
index c4200ff5a..3bd37082f 100644
--- a/ethstaker_deposit/deposit.py
+++ b/ethstaker_deposit/deposit.py
@@ -31,7 +31,7 @@ def check_python_version() -> None:
     '''
     if sys.version_info < (3, 9):
         click.pause(load_text(['err_python_version']))
-        sys.exit()
+        sys.exit(78)
 
 
 def check_connectivity() -> None:
diff --git a/ethstaker_deposit/intl/en/utils/validation.json b/ethstaker_deposit/intl/en/utils/validation.json
index 8a58b94af..9559e3f66 100644
--- a/ethstaker_deposit/intl/en/utils/validation.json
+++ b/ethstaker_deposit/intl/en/utils/validation.json
@@ -3,7 +3,9 @@
         "msg_deposit_verification": "Verifying your deposit_data-*.json file(s):\t"
     },
     "validate_password_strength": {
-        "msg_password_length": "The password length should be at least 8. Please retype"
+        "msg_password_length": "The password length should be at least 8. Please retype.",
+        "msg_password_utf8_win32": "Your terminal is not UTF-8 encoded. To ensure the keystore file can be imported on Linux, the password should contain only English-language characters. Please retype.",
+        "msg_password_utf8": "Your terminal is not UTF-8 encoded. To ensure the keystore file can be imported on Linux, the password should contain only English-language characters. Please retype.\nAlternatively, you can quit this program and relaunch it from a UTF-8 encoded terminal."
     },
     "validate_int_range": {
         "err_not_positive_integer": "That is not a positive integer. Please retype."
@@ -14,7 +16,7 @@
     "validate_withdrawal_address": {
         "err_invalid_ECDSA_hex_addr": "The given execution address is not in hexadecimal encoded form.",
         "err_invalid_ECDSA_hex_addr_checksum": "The given execution address is not in checksum form.",
-        "err_missing_address": "An address must must be providing",
+        "err_missing_address": "An address must be provided",
         "msg_ECDSA_hex_addr_withdrawal": "**[Warning] you are setting an execution address as your withdrawal address. Please ensure that you have control over this address.**"
     },
     "validate_partial_deposit_amount": {
diff --git a/ethstaker_deposit/utils/validation.py b/ethstaker_deposit/utils/validation.py
index b8524c905..4dd3e5e8c 100644
--- a/ethstaker_deposit/utils/validation.py
+++ b/ethstaker_deposit/utils/validation.py
@@ -2,6 +2,7 @@
 import click
 import json
 import re
+import sys
 import concurrent.futures
 from typing import Any, Dict, Sequence
 
@@ -138,6 +139,14 @@ def validate_deposit(deposit_data_dict: Dict[str, Any], credential: Credential =
 def validate_password_strength(password: str) -> str:
     if len(password) < 8:
         raise ValidationError(load_text(['msg_password_length']))
+
+    encoding = sys.stdin.encoding.lower()
+    if encoding != 'utf-8' and not password.isascii():
+        if sys.platform == 'win32':
+            raise ValidationError(load_text(['msg_password_utf8_win32']))
+        else:
+            raise ValidationError(load_text(['msg_password_utf8']))
+
     return password
 
 


Test patch:
diff --git a/tests/test_deposit.py b/tests/test_deposit.py
index a7f4048d9..48d03907f 100644
--- a/tests/test_deposit.py
+++ b/tests/test_deposit.py
@@ -12,7 +12,7 @@
 def test_should_notify_user_and_exit_if_invalid_python_version(monkeypatch) -> None:
     exit_called = False
 
-    def _mock_sys_exit():
+    def _mock_sys_exit(arg):
         nonlocal exit_called
         exit_called = True
 
diff --git a/tests/test_utils/test_validation.py b/tests/test_utils/test_validation.py
index ee00473df..1c0ecf716 100644
--- a/tests/test_utils/test_validation.py
+++ b/tests/test_utils/test_validation.py
@@ -1,4 +1,5 @@
 import pytest
+import sys
 from typing import (
     Any,
 )
@@ -21,7 +22,38 @@
         ('1234567', False),
     ]
 )
-def test_validate_password_strength(password, valid):
+def test_validate_password_strength(password: str, valid: bool) -> None:
+    if valid:
+        validate_password_strength(password=password)
+    else:
+        with pytest.raises(ValidationError):
+            validate_password_strength(password=password)
+
+
+@pytest.mark.parametrize(
+    'password, encoding, platform, valid',
+    [
+        ('Surströmming', 'utf-8', 'linux', True),
+        ('Surströmming', 'latin-1', 'linux', False),
+        ('Surstromming', 'latin-1', 'linux', True),
+        ('Surströmming', 'utf-8', 'darwin', True),
+        ('Surströmming', 'latin-1', 'darwin', False),
+        ('Surstromming', 'latin-1', 'darwin', True),
+        ('Surströmming', 'utf-8', 'win32', True),
+        ('Surströmming', 'latin-1', 'win32', False),
+        ('Surstromming', 'latin-1', 'win32', True),
+    ]
+)
+def test_validate_password_strength_encoding(
+    monkeypatch, password: str, encoding: str, platform: str, valid: bool
+) -> None:
+    class MockStdin:
+        def __init__(self, encoding):
+            self.encoding = encoding
+    mock_stdin = MockStdin(encoding=encoding)
+    monkeypatch.setattr(sys, 'stdin', mock_stdin)
+    monkeypatch.setattr(sys, 'platform', platform)
+
     if valid:
         validate_password_strength(password=password)
     else:
@@ -119,7 +151,9 @@ def test_normalize_input_list(input, result):
         ('mainnet', 0, 0, valid_pubkey, invalid_signature, False),
     ]
 )
-def test_validate_signed_exit(chain, epoch, validator_index, pubkey, signature, result):
+def test_validate_signed_exit(
+    chain: str, epoch: int, validator_index: int, pubkey: str, signature: str, result: bool
+) -> None:
     chain_settings = get_chain_setting(chain)
 
     assert validate_signed_exit(validator_index, epoch, signature, pubkey, chain_settings) == result