Skip to content

Commit 0ef2fd3

Browse files
committed
Feature: create/import keystore wallet (password-encrypted) + docstrings
1 parent c62fbb7 commit 0ef2fd3

File tree

3 files changed

+170
-25
lines changed

3 files changed

+170
-25
lines changed

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

+129-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import json
12
import logging
23
from abc import ABC, abstractmethod
4+
from functools import lru_cache
35
from pathlib import Path
46
from typing import Dict, Optional
57

68
from coincurve.keys import PrivateKey
9+
from rich.prompt import Console, Prompt, Text
710
from typing_extensions import deprecated
11+
from web3 import Web3
812

913
from aleph.sdk.conf import settings
1014
from aleph.sdk.utils import enum_as_str
@@ -143,22 +147,141 @@ async def decrypt(self, content: bytes) -> bytes:
143147
raise NotImplementedError
144148

145149

146-
# Start of the ugly stuff
147150
def generate_key() -> bytes:
151+
"""
152+
Generate a new private key.
153+
154+
Returns:
155+
bytes: The generated private key as bytes.
156+
"""
157+
148158
privkey = PrivateKey()
149159
return privkey.secret
150160

151161

162+
def create_key() -> bytes:
163+
"""
164+
Create or import a private key.
165+
166+
This function allows the user to either import an existing private key
167+
or generate a new one. If the user chooses to import a key, they can
168+
enter a private key in hexadecimal format or a passphrase.
169+
170+
Returns:
171+
bytes: The private key as bytes. 7e0c27fff7e434ec5aa47127e7bcdce81c4eba6f3ce980f425b60b1cd019a947
172+
"""
173+
if Prompt.ask("Import an existing wallet", choices=["y", "n"], default="n") == "y":
174+
data = Prompt.ask("Enter your private key or passphrase")
175+
# private key
176+
if data.startswith("0x") and len(data) == 66:
177+
return bytes.fromhex(data[2:])
178+
elif len(data) == 64:
179+
return bytes.fromhex(data)
180+
# passphrase
181+
elif len(data.split()) in [12, 24]:
182+
w3 = Web3()
183+
w3.eth.account.enable_unaudited_hdwallet_features()
184+
return w3.eth.account.from_mnemonic(data.strip()).key
185+
raise ValueError("Invalid private key or passphrase")
186+
return generate_key()
187+
188+
189+
def save_key(private_key: bytes, path: Path):
190+
"""
191+
Save a private key to a file.
192+
193+
Parameters:
194+
private_key (bytes): The private key as bytes.
195+
path (Path): The path to the private key file.
196+
197+
Returns:
198+
None
199+
"""
200+
address = None
201+
path.parent.mkdir(exist_ok=True, parents=True)
202+
if path.name.endswith(".key"):
203+
path.write_bytes(private_key)
204+
address = Web3().to_checksum_address(
205+
Web3().eth.account.from_key(private_key).address
206+
)
207+
elif path.name.endswith(".json"):
208+
password = Prompt.ask(
209+
"Enter a password to encrypt your keystore", password=True
210+
)
211+
keystore = Web3().eth.account.encrypt(private_key, password)
212+
path.write_text(json.dumps(keystore))
213+
address = Web3().to_checksum_address(
214+
Web3().eth.account.from_key(private_key).address
215+
)
216+
if not address:
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+
raise ValueError("Unsupported private key file format")
259+
260+
152261
def get_fallback_private_key(path: Optional[Path] = None) -> bytes:
262+
"""
263+
Retrieve or create a fallback private key.
264+
265+
This function attempts to load a private key from the specified path.
266+
If the path is not provided, it defaults to the path specified in the
267+
settings. If the file does not exist or is empty, a new private key
268+
is generated and saved to the specified path. A symlink is also created
269+
to use this key by default.
270+
271+
Parameters:
272+
path (Optional[Path]): The path to the private key file. If not provided,
273+
the default path from settings is used.
274+
275+
Returns:
276+
bytes: The private key as bytes.
277+
"""
153278
path = path or settings.PRIVATE_KEY_FILE
154279
private_key: bytes
155280
if path.exists() and path.stat().st_size > 0:
156-
private_key = path.read_bytes()
281+
private_key = load_key(path)
157282
else:
158-
private_key = generate_key()
159-
path.parent.mkdir(exist_ok=True, parents=True)
160-
path.write_bytes(private_key)
161-
283+
private_key = create_key()
284+
save_key(private_key, path)
162285
default_key_path = path.parent / "default.key"
163286

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

src/aleph/sdk/conf.py

+19-17
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",
@@ -115,16 +119,16 @@ class Settings(BaseSettings):
115119
),
116120
}
117121
# Add all placeholders to allow easy dynamic setup of CHAINS
118-
CHAINS_SEPOLIA_ACTIVE: Optional[bool]
119-
CHAINS_ETH_ACTIVE: Optional[bool]
120-
CHAINS_AVAX_ACTIVE: Optional[bool]
121-
CHAINS_BASE_ACTIVE: Optional[bool]
122-
CHAINS_BSC_ACTIVE: Optional[bool]
123-
CHAINS_SEPOLIA_RPC: Optional[str]
124-
CHAINS_ETH_RPC: Optional[str]
125-
CHAINS_AVAX_RPC: Optional[str]
126-
CHAINS_BASE_RPC: Optional[str]
127-
CHAINS_BSC_RPC: Optional[str]
122+
CHAINS_SEPOLIA_ACTIVE: Optional[bool] = None
123+
CHAINS_ETH_ACTIVE: Optional[bool] = None
124+
CHAINS_AVAX_ACTIVE: Optional[bool] = None
125+
CHAINS_BASE_ACTIVE: Optional[bool] = None
126+
CHAINS_BSC_ACTIVE: Optional[bool] = None
127+
CHAINS_SEPOLIA_RPC: Optional[str] = None
128+
CHAINS_ETH_RPC: Optional[str] = None
129+
CHAINS_AVAX_RPC: Optional[str] = None
130+
CHAINS_BASE_RPC: Optional[str] = None
131+
CHAINS_BSC_RPC: Optional[str] = None
128132

129133
# Dns resolver
130134
DNS_IPFS_DOMAIN = "ipfs.public.aleph.sh"
@@ -153,11 +157,9 @@ class Config:
153157
settings = Settings()
154158

155159
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-
160+
pk_file = str(settings.PRIVATE_KEY_FILE.name)
161+
if pk_file.endswith(".key") or pk_file.endswith(".json"):
162+
settings.PRIVATE_KEY_FILE = Path(settings.CONFIG_HOME, "private-keys", pk_file)
161163
if str(settings.PRIVATE_MNEMONIC_FILE) == "substrate.mnemonic":
162164
settings.PRIVATE_MNEMONIC_FILE = Path(
163165
settings.CONFIG_HOME, "private-keys", "substrate.mnemonic"

0 commit comments

Comments
 (0)