Skip to content

Commit 3c62dda

Browse files
committed
feat: create/import keystore wallet (password-encrypted) + add docstrings
1 parent c62fbb7 commit 3c62dda

File tree

4 files changed

+167
-15
lines changed

4 files changed

+167
-15
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"aleph-superfluid>=0.2.1",
3535
"eth_typing==4.3.1",
3636
"web3==6.3.0",
37+
"rich==13.7.1",
3738
]
3839

3940
[project.optional-dependencies]

src/aleph/sdk/account.py

+22-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pathlib import Path
44
from typing import Optional, Type, TypeVar
55

6-
from aleph.sdk.chains.common import get_fallback_private_key
6+
from aleph.sdk.chains.common import get_fallback_private_key, load_key
77
from aleph.sdk.chains.ethereum import ETHAccount
88
from aleph.sdk.chains.remote import RemoteAccount
99
from aleph.sdk.conf import settings
@@ -15,13 +15,33 @@
1515

1616

1717
def account_from_hex_string(private_key_str: str, account_type: Type[T]) -> T:
18+
"""
19+
Loads an account from a hexadecimal string representation of a private key.
20+
21+
Args:
22+
private_key_str (str): The private key as a hexadecimal string.
23+
account_type (Type[T]): The type of account to load.
24+
25+
Returns:
26+
T: An instance of the specified account type.
27+
"""
1828
if private_key_str.startswith("0x"):
1929
private_key_str = private_key_str[2:]
2030
return account_type(bytes.fromhex(private_key_str))
2131

2232

2333
def account_from_file(private_key_path: Path, account_type: Type[T]) -> T:
24-
private_key = private_key_path.read_bytes()
34+
"""
35+
Loads an account from a private key stored in a file (plain text or keystore).
36+
37+
Args:
38+
private_key_path (Path): The path to the file containing the private key.
39+
account_type (Type[T]): The type of account to load.
40+
41+
Returns:
42+
T: An instance of the specified account type.
43+
"""
44+
private_key = load_key(private_key_path)
2545
return account_type(private_key)
2646

2747

src/aleph/sdk/chains/common.py

+134-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import json
12
import logging
3+
import sys
24
from abc import ABC, abstractmethod
5+
from functools import lru_cache
36
from pathlib import Path
47
from typing import Dict, Optional
58

69
from coincurve.keys import PrivateKey
10+
from rich.prompt import Console, Prompt, Text
711
from typing_extensions import deprecated
12+
from web3 import Web3
813

914
from aleph.sdk.conf import settings
1015
from aleph.sdk.utils import enum_as_str
@@ -143,22 +148,145 @@ async def decrypt(self, content: bytes) -> bytes:
143148
raise NotImplementedError
144149

145150

146-
# Start of the ugly stuff
147151
def generate_key() -> bytes:
152+
"""
153+
Generate a new private key.
154+
155+
Returns:
156+
bytes: The generated private key as bytes.
157+
"""
158+
148159
privkey = PrivateKey()
149160
return privkey.secret
150161

151162

163+
def create_or_import_key() -> bytes:
164+
"""
165+
Create or import a private key.
166+
167+
This function allows the user to either import an existing private key
168+
or generate a new one. If the user chooses to import a key, they can
169+
enter a private key in hexadecimal format or a mnemonic seed phrase.
170+
171+
Returns:
172+
bytes: The private key as bytes.
173+
"""
174+
if Prompt.ask("Import an existing wallet", choices=["y", "n"], default="n") == "y":
175+
data = Prompt.ask("Enter your private key or mnemonic seed phrase")
176+
# private key
177+
if data.startswith("0x"):
178+
data = data[2:]
179+
if len(data) == 64:
180+
return bytes.fromhex(data)
181+
# mnemonic seed phrase
182+
elif len(data.split()) in [12, 24]:
183+
w3 = Web3()
184+
w3.eth.account.enable_unaudited_hdwallet_features()
185+
return w3.eth.account.from_mnemonic(data.strip()).key
186+
else:
187+
raise ValueError("Invalid private key or mnemonic seed phrase")
188+
else:
189+
return generate_key()
190+
191+
192+
def save_key(private_key: bytes, path: Path):
193+
"""
194+
Save a private key to a file.
195+
196+
Parameters:
197+
private_key (bytes): The private key as bytes.
198+
path (Path): The path to the private key file.
199+
200+
Returns:
201+
None
202+
"""
203+
w3 = Web3()
204+
address = None
205+
path.parent.mkdir(exist_ok=True, parents=True)
206+
if path.name.endswith(".key") or "pytest" in sys.modules:
207+
address = w3.to_checksum_address(w3.eth.account.from_key(private_key).address)
208+
path.write_bytes(private_key)
209+
elif path.name.endswith(".json"):
210+
address = w3.to_checksum_address(w3.eth.account.from_key(private_key).address)
211+
password = Prompt.ask(
212+
"Enter a password to encrypt your keystore", password=True
213+
)
214+
keystore = w3.eth.account.encrypt(private_key, password)
215+
path.write_text(json.dumps(keystore))
216+
else:
217+
raise ValueError("Unsupported private key file format")
218+
confirmation = Text.assemble(
219+
"\nYour address: ",
220+
Text(address, style="cyan"),
221+
"\nSaved file: ",
222+
Text(str(path), style="orange1"),
223+
"\n",
224+
)
225+
Console().print(confirmation)
226+
227+
228+
@lru_cache(maxsize=1)
229+
def load_key(private_key_path: Path) -> bytes:
230+
"""
231+
Load a private key from a file.
232+
233+
This function supports two types of private key files:
234+
1. Unencrypted .key files.
235+
2. Encrypted .json keystore files.
236+
237+
Parameters:
238+
private_key_path (Path): The path to the private key file.
239+
240+
Returns:
241+
bytes: The private key as bytes.
242+
243+
Raises:
244+
FileNotFoundError: If the private key file does not exist.
245+
ValueError: If the private key file is not a .key or .json file.
246+
"""
247+
if not private_key_path.exists():
248+
raise FileNotFoundError("Private key file not found")
249+
elif private_key_path.name.endswith(".key"):
250+
return private_key_path.read_bytes()
251+
elif private_key_path.name.endswith(".json"):
252+
keystore = private_key_path.read_text()
253+
password = Prompt.ask("Keystore password", password=True)
254+
try:
255+
return Web3().eth.account.decrypt(keystore, password)
256+
except ValueError:
257+
raise ValueError("Invalid password")
258+
else:
259+
raise ValueError("Unsupported private key file format")
260+
261+
152262
def get_fallback_private_key(path: Optional[Path] = None) -> bytes:
263+
"""
264+
Retrieve or create a fallback private key.
265+
266+
This function attempts to load a private key from the specified path.
267+
If the path is not provided, it defaults to the path specified in the
268+
settings. If the file does not exist or is empty, a new private key
269+
is generated and saved to the specified path. A symlink is also created
270+
to use this key by default.
271+
272+
Parameters:
273+
path (Optional[Path]): The path to the private key file. If not provided,
274+
the default path from settings is used.
275+
276+
Returns:
277+
bytes: The private key as bytes.
278+
"""
153279
path = path or settings.PRIVATE_KEY_FILE
154280
private_key: bytes
155281
if path.exists() and path.stat().st_size > 0:
156-
private_key = path.read_bytes()
282+
private_key = load_key(path)
157283
else:
158-
private_key = generate_key()
159-
path.parent.mkdir(exist_ok=True, parents=True)
160-
path.write_bytes(private_key)
161-
284+
private_key = (
285+
generate_key()
286+
if path.name.endswith(".key") or "pytest" in sys.modules
287+
else create_or_import_key()
288+
)
289+
save_key(private_key, path)
162290
default_key_path = path.parent / "default.key"
163291

164292
# If the symlink exists but does not point to a file, delete it.

src/aleph/sdk/conf.py

+10-7
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313
class Settings(BaseSettings):
1414
CONFIG_HOME: Optional[str] = None
1515

16-
# In case the user does not want to bother with handling private keys himself,
17-
# do an ugly and insecure write and read from disk to this file.
16+
# Two methods for storing your private key:
17+
# 1. *.key: The private key is written to and read from an unencrypted file.
18+
# This method is less secure as the key is stored in plain text.
19+
# 2. *.json: The private key is stored in a keystore file, encrypted with a password.
20+
# This method is more secure as the key is protected by encryption.
21+
# If the file is missing, a new private key will be created.
1822
PRIVATE_KEY_FILE: Path = Field(
1923
default=Path("ethereum.key"),
2024
description="Path to the private key used to sign messages and transactions",
@@ -152,12 +156,11 @@ class Config:
152156

153157
settings = Settings()
154158

159+
# Corrected private key file path (encrypted or not)
155160
assert settings.CONFIG_HOME
156-
if str(settings.PRIVATE_KEY_FILE) == "ethereum.key":
157-
settings.PRIVATE_KEY_FILE = Path(
158-
settings.CONFIG_HOME, "private-keys", "ethereum.key"
159-
)
160-
161+
pk_file = str(settings.PRIVATE_KEY_FILE.name)
162+
if pk_file.endswith(".key") or pk_file.endswith(".json"):
163+
settings.PRIVATE_KEY_FILE = Path(settings.CONFIG_HOME, "private-keys", pk_file)
161164
if str(settings.PRIVATE_MNEMONIC_FILE) == "substrate.mnemonic":
162165
settings.PRIVATE_MNEMONIC_FILE = Path(
163166
settings.CONFIG_HOME, "private-keys", "substrate.mnemonic"

0 commit comments

Comments
 (0)