From cb9096da718e4ff4c6887c107c9e2479ee3c9a06 Mon Sep 17 00:00:00 2001 From: 0xparashar Date: Tue, 10 Feb 2026 23:41:49 +0530 Subject: [PATCH] added keystore based private keys and encryption with user provided password --- lume/__init__.py | 20 ++- lume/agent.py | 92 +++++++++++- lume/utils.py | 89 ++++++++++++ skills/lume-trading/SKILL.md | 67 +++++---- .../lume-trading/references/API_REFERENCE.md | 134 +++++++++++++++++- skills/lume-trading/references/EXAMPLES.md | 45 +++++- .../references/TROUBLESHOOTING.md | 103 ++++++++++++++ 7 files changed, 515 insertions(+), 35 deletions(-) diff --git a/lume/__init__.py b/lume/__init__.py index 457ae0d..2fbc3fb 100644 --- a/lume/__init__.py +++ b/lume/__init__.py @@ -67,7 +67,17 @@ Outcome, RelayResponse, ) -from lume.utils import from_atomic, generate_wallet, to_atomic +from lume.utils import ( + DEFAULT_KEYSTORE_PATH, + decrypt_private_key, + encrypt_private_key, + from_atomic, + generate_wallet, + keystore_exists, + load_keystore, + save_keystore, + to_atomic, +) from lume.websocket import GraphQLWebSocketClient __version__ = "0.1.0" @@ -127,9 +137,15 @@ "SettlementBatch", "SubscriptionManager", # Utils + "DEFAULT_KEYSTORE_PATH", + "decrypt_private_key", + "encrypt_private_key", + "from_atomic", "generate_wallet", + "keystore_exists", + "load_keystore", + "save_keystore", "to_atomic", - "from_atomic", # Types "Event", "Market", diff --git a/lume/agent.py b/lume/agent.py index 680ad89..294f21c 100644 --- a/lume/agent.py +++ b/lume/agent.py @@ -29,12 +29,24 @@ from __future__ import annotations +import getpass from dataclasses import dataclass from typing import Any, Optional +from eth_account import Account + from lume.client import LumeClient from lume.constants import SIGNATURE_TYPE_POLY_GNOSIS_SAFE, get_config_with_env_overrides from lume.exceptions import GraphQLError +from lume.utils import ( + DEFAULT_KEYSTORE_PATH, + decrypt_private_key, + encrypt_private_key, + generate_wallet, + keystore_exists, + load_keystore, + save_keystore, +) class AgentRegistrationError(GraphQLError): @@ -326,38 +338,108 @@ def verify_authentication(self) -> bool: return False +def _resolve_private_key_interactive(keystore_path: str) -> str: + """Resolve a private key interactively via keystore. + + If a keystore file exists at *keystore_path*, the user is prompted for the + password (up to 3 attempts). Otherwise a new wallet is generated, the user + is asked to create a password (with confirmation), and the encrypted + keystore is persisted. + + Returns: + Hex private key **without** the ``0x`` prefix. + + Raises: + ValueError: After 3 failed password attempts or mismatched confirmation. + """ + + if keystore_exists(keystore_path): + keystore = load_keystore(keystore_path) + for attempt in range(1, 4): + password = getpass.getpass( + f"Enter keystore password ({attempt}/3): " + ) + try: + private_key = decrypt_private_key(keystore, password) + address = Account.from_key(private_key).address + print(f"Wallet loaded: {address}") + print(f"Keystore: {keystore_path}") + return private_key + except ValueError: + if attempt < 3: + print("Incorrect password, try again.") + raise ValueError("Failed to decrypt keystore after 3 attempts") + + # No existing keystore — generate a new wallet + private_key, address = generate_wallet() + + password = getpass.getpass("Create keystore password: ") + password_confirm = getpass.getpass("Confirm keystore password: ") + if password != password_confirm: + raise ValueError("Passwords do not match") + + keystore = encrypt_private_key(private_key, password) + saved_path = save_keystore(keystore, keystore_path) + print(f"New wallet generated: {address}") + print(f"Keystore saved: {saved_path}") + return private_key + + def create_agent_from_env( agent_id: Optional[str] = None, display_name: Optional[str] = None, auto_register: bool = True, + keystore_path: Optional[str] = None, + use_keystore: bool = True, ) -> AgentClient: """Create an AgentClient from environment variables. Reads configuration from environment: - - LUME_PRIVATE_KEY or PRIVATE_KEY: Required wallet private key + - LUME_PRIVATE_KEY or PRIVATE_KEY: Wallet private key - LUME_API_URL: Optional API URL override - LUME_AGENT_ID: Optional agent ID (overridden by agent_id param) - LUME_AGENT_NAME: Optional display name (overridden by display_name param) + - LUME_KEYSTORE_PATH: Optional custom keystore file path + + **Priority chain** for resolving the private key: + + 1. ``LUME_PRIVATE_KEY`` / ``PRIVATE_KEY`` environment variable + 2. Encrypted keystore file (if *use_keystore* is ``True``) + 3. Generate a new wallet and save to keystore Args: agent_id: Agent identifier (defaults to LUME_AGENT_ID env var) display_name: Display name (defaults to LUME_AGENT_NAME env var) auto_register: Whether to register automatically + keystore_path: Path to keystore JSON file (defaults to + ``LUME_KEYSTORE_PATH`` env var or ``~/.lume/keystore.json``) + use_keystore: If ``True`` (default) and no env-var key is present, + interactively resolve the key via keystore. If ``False``, + a missing env var raises ``ValueError``. Returns: Configured AgentClient instance Raises: - ValueError: If PRIVATE_KEY is not set + ValueError: If no private key can be resolved AgentRegistrationError: If auto_register is True and registration fails """ import os private_key = os.getenv("LUME_PRIVATE_KEY") or os.getenv("PRIVATE_KEY") + if not private_key: - raise ValueError( - "LUME_PRIVATE_KEY or PRIVATE_KEY environment variable is required" - ) + if use_keystore: + resolved_path = ( + keystore_path + or os.getenv("LUME_KEYSTORE_PATH") + or DEFAULT_KEYSTORE_PATH + ) + private_key = _resolve_private_key_interactive(resolved_path) + else: + raise ValueError( + "LUME_PRIVATE_KEY or PRIVATE_KEY environment variable is required" + ) api_url = os.getenv("LUME_API_URL") resolved_agent_id = agent_id or os.getenv("LUME_AGENT_ID") diff --git a/lume/utils.py b/lume/utils.py index 5b2a815..2c6c178 100644 --- a/lume/utils.py +++ b/lume/utils.py @@ -2,10 +2,16 @@ from __future__ import annotations +import json +import os from decimal import Decimal, ROUND_DOWN +from pathlib import Path from eth_account import Account +DEFAULT_KEYSTORE_DIR: str = os.path.join(str(Path.home()), ".lume") +DEFAULT_KEYSTORE_PATH: str = os.path.join(DEFAULT_KEYSTORE_DIR, "keystore.json") + def generate_wallet() -> tuple[str, str]: """Generate a new Ethereum private key and address. @@ -51,3 +57,86 @@ def from_atomic(amount: int) -> Decimal: Decimal value (e.g. ``Decimal('1.500000')``). """ return Decimal(amount) / Decimal("1000000") + + +def encrypt_private_key(private_key: str, password: str) -> dict: + """Encrypt a private key using Ethereum keystore v3 format. + + Args: + private_key: Hex private key (with or without 0x prefix). + password: Password to encrypt with. + + Returns: + Keystore v3 JSON-compatible dict. + """ + if not private_key.startswith("0x"): + private_key = "0x" + private_key + return Account.encrypt(private_key, password) + + +def decrypt_private_key(keystore: dict, password: str) -> str: + """Decrypt a private key from an Ethereum keystore v3 dict. + + Args: + keystore: Keystore v3 dict (as returned by :func:`encrypt_private_key`). + password: Password used during encryption. + + Returns: + Hex private key *without* the ``0x`` prefix. + + Raises: + ValueError: If the password is incorrect. + """ + try: + raw = Account.decrypt(keystore, password) + return raw.hex() + except Exception as exc: + raise ValueError("Incorrect password or corrupted keystore") from exc + + +def save_keystore(keystore: dict, path: str = DEFAULT_KEYSTORE_PATH) -> str: + """Persist a keystore dict to disk with restrictive permissions. + + Creates the parent directory (mode 0o700) if it does not exist and + writes the file with mode 0o600 so only the current user can read it. + + Args: + keystore: Keystore v3 dict. + path: Destination file path. + + Returns: + The absolute path where the keystore was saved. + """ + directory = os.path.dirname(path) + os.makedirs(directory, mode=0o700, exist_ok=True) + with open(path, "w") as f: + json.dump(keystore, f, indent=2) + os.chmod(path, 0o600) + return os.path.abspath(path) + + +def load_keystore(path: str = DEFAULT_KEYSTORE_PATH) -> dict | None: + """Load a keystore dict from disk. + + Args: + path: Path to the keystore JSON file. + + Returns: + The parsed keystore dict, or ``None`` if the file does not exist. + """ + if not os.path.isfile(path): + return None + with open(path) as f: + return json.load(f) + + +def keystore_exists(path: str = DEFAULT_KEYSTORE_PATH) -> bool: + """Check whether a keystore file exists at *path*. + + Args: + path: Path to check. + + Returns: + ``True`` if the file exists, ``False`` otherwise. + """ + return os.path.isfile(path) diff --git a/skills/lume-trading/SKILL.md b/skills/lume-trading/SKILL.md index bdadc4f..4278aa1 100644 --- a/skills/lume-trading/SKILL.md +++ b/skills/lume-trading/SKILL.md @@ -66,50 +66,69 @@ You can override individual settings with environment variables: `LUME_ENV`, `LUME_API_URL`, `LUME_CHAIN_ID`, `LUME_CTF_EXCHANGE_ADDRESS`, `LUME_NEGRISK_EXCHANGE_ADDRESS`, `LUME_FEE_RATE_BPS`. -### Initialize the agent +### Initialize the agent — IMPORTANT: Follow this procedure -For autonomous agents, use `AgentClient` (extends `LumeClient` with -registration and authentication): +Before ANY trading operation, you MUST resolve the agent's private key. Follow +this priority chain **in order** — stop at the first one that succeeds: -```python -import os -from lume import AgentClient +#### Priority 1: Encrypted keystore (highest) -agent = AgentClient(private_key=os.environ["PRIVATE_KEY"]) -info = agent.register() +Check if `~/.lume/keystore.json` exists (or `LUME_KEYSTORE_PATH` env var). +Since `getpass` does not work in non-interactive shells, you MUST ask the user +for their password using `AskUserQuestion`, then decrypt programmatically: -print(f"EOA Address: {agent.eoa_address}") -print(f"Proxy Wallet: {agent.proxy_wallet}") -print(f"Registered: {info.is_registered}") +```python +from lume import keystore_exists, load_keystore, decrypt_private_key + +if keystore_exists(): + # keystore found — ASK the user for their password via AskUserQuestion, + # then decrypt: + keystore = load_keystore() + private_key = decrypt_private_key(keystore, password) # password from user ``` -Or use the environment helper: +If decryption fails (`ValueError`), tell the user the password was wrong and +ask again (up to 3 attempts). -```python -from lume import create_agent_from_env +#### Priority 2: Environment variable + +If no keystore exists, check `LUME_PRIVATE_KEY` or `PRIVATE_KEY`: -# Reads LUME_PRIVATE_KEY (or PRIVATE_KEY), LUME_API_URL, LUME_AGENT_ID, LUME_AGENT_NAME -agent = create_agent_from_env(agent_id="my-bot", display_name="My Trading Bot") +```python +import os +private_key = os.getenv("LUME_PRIVATE_KEY") or os.getenv("PRIVATE_KEY") ``` -You can also use `LumeClient` directly if you don't need agent registration: +#### Priority 3: Generate new wallet + +If neither keystore nor env var is available, generate a new wallet, ask the +user to create a password (with confirmation), encrypt, and save: ```python -from lume import LumeClient +from lume import generate_wallet, encrypt_private_key, save_keystore -client = LumeClient(private_key=os.environ["PRIVATE_KEY"]) +private_key, address = generate_wallet() +# ASK user to provide a password via AskUserQuestion +keystore = encrypt_private_key(private_key, password) +save_keystore(keystore) ``` -### Generate a new wallet (if needed) +#### Create the AgentClient + +Once you have the `private_key`, create the agent directly: ```python -from lume import generate_wallet +from lume import AgentClient -private_key, address = generate_wallet() -print(f"Address: {address}") -# Save private_key securely +agent = AgentClient(private_key=private_key, auto_register=True) +print(f"EOA Address: {agent.eoa_address}") +print(f"Proxy Wallet: {agent.proxy_wallet}") ``` +> **Note:** Do NOT use `create_agent_from_env()` from within this skill because +> its `getpass` prompts fail in non-interactive shells. Always resolve the key +> yourself using the procedure above, then pass it to `AgentClient` directly. + ## Key Concepts ### Price format: 1e6 atomic units diff --git a/skills/lume-trading/references/API_REFERENCE.md b/skills/lume-trading/references/API_REFERENCE.md index c72de2b..0188bf3 100644 --- a/skills/lume-trading/references/API_REFERENCE.md +++ b/skills/lume-trading/references/API_REFERENCE.md @@ -68,20 +68,43 @@ Get current wallet authentication headers for custom requests. **Returns:** Dict with `X-Wallet-Address`, `X-Wallet-Nonce`, `X-Wallet-Timestamp`, `X-Wallet-Signature`. -### `create_agent_from_env(agent_id=None, display_name=None, auto_register=True) -> AgentClient` +### `create_agent_from_env(agent_id=None, display_name=None, auto_register=True, keystore_path=None, use_keystore=True) -> AgentClient` -Factory function to create an `AgentClient` from environment variables. +Factory function to create an `AgentClient` from environment variables. Supports +automatic encrypted keystore management when no private key env var is set. + +**Priority chain** for resolving the private key: +1. `LUME_PRIVATE_KEY` / `PRIVATE_KEY` env var +2. Encrypted keystore file (if `use_keystore=True`) +3. Generate new wallet and save to keystore + +**Parameters:** +| Name | Type | Description | +|------|------|-------------| +| `agent_id` | `str \| None` | Agent identifier (defaults to `LUME_AGENT_ID` env var) | +| `display_name` | `str \| None` | Display name (defaults to `LUME_AGENT_NAME` env var) | +| `auto_register` | `bool` | Whether to register automatically (default: True) | +| `keystore_path` | `str \| None` | Path to keystore JSON (defaults to `LUME_KEYSTORE_PATH` env var or `~/.lume/keystore.json`) | +| `use_keystore` | `bool` | If `True` (default), use interactive keystore when no env var key is set. If `False`, missing env var raises `ValueError`. | **Environment variables:** -- `LUME_PRIVATE_KEY` or `PRIVATE_KEY` (required) +- `LUME_PRIVATE_KEY` or `PRIVATE_KEY` (optional if keystore enabled) - `LUME_API_URL` (optional) - `LUME_AGENT_ID` (optional, overridden by `agent_id` param) - `LUME_AGENT_NAME` (optional, overridden by `display_name` param) +- `LUME_KEYSTORE_PATH` (optional, custom keystore file path) ```python from lume import create_agent_from_env +# With env var set -- uses PRIVATE_KEY directly, keystore ignored agent = create_agent_from_env(agent_id="my-bot") + +# Without env var -- prompts for password, loads/creates keystore +agent = create_agent_from_env() + +# Disable keystore (old behavior: require env var) +agent = create_agent_from_env(use_keystore=False) ``` --- @@ -847,6 +870,111 @@ from lume import generate_wallet private_key, address = generate_wallet() ``` +### `DEFAULT_KEYSTORE_PATH` + +Default keystore file path: `~/.lume/keystore.json`. Used by `create_agent_from_env()` +and the keystore utility functions as the default `path` argument. + +```python +from lume import DEFAULT_KEYSTORE_PATH + +print(DEFAULT_KEYSTORE_PATH) # e.g., /home/user/.lume/keystore.json +``` + +### `encrypt_private_key(private_key: str, password: str) -> dict` + +Encrypt a private key using Ethereum keystore v3 format. + +**Parameters:** +| Name | Type | Description | +|------|------|-------------| +| `private_key` | `str` | Hex private key (with or without 0x prefix) | +| `password` | `str` | Password to encrypt with | + +**Returns:** Keystore v3 JSON-compatible dict. + +```python +from lume import encrypt_private_key + +keystore = encrypt_private_key("aabbccdd" * 8, "my-password") +``` + +### `decrypt_private_key(keystore: dict, password: str) -> str` + +Decrypt a private key from an Ethereum keystore v3 dict. + +**Parameters:** +| Name | Type | Description | +|------|------|-------------| +| `keystore` | `dict` | Keystore v3 dict | +| `password` | `str` | Password used during encryption | + +**Returns:** Hex private key without the `0x` prefix. + +**Raises:** `ValueError` if the password is incorrect or keystore is corrupted. + +```python +from lume import decrypt_private_key + +private_key = decrypt_private_key(keystore, "my-password") +``` + +### `save_keystore(keystore: dict, path: str = DEFAULT_KEYSTORE_PATH) -> str` + +Persist a keystore dict to disk. Creates the parent directory with mode `0o700` +and writes the file with mode `0o600`. + +**Parameters:** +| Name | Type | Description | +|------|------|-------------| +| `keystore` | `dict` | Keystore v3 dict | +| `path` | `str` | Destination file path (default: `~/.lume/keystore.json`) | + +**Returns:** Absolute path where the keystore was saved. + +```python +from lume import save_keystore + +saved_path = save_keystore(keystore, "/custom/path/keystore.json") +``` + +### `load_keystore(path: str = DEFAULT_KEYSTORE_PATH) -> dict | None` + +Load a keystore dict from disk. + +**Parameters:** +| Name | Type | Description | +|------|------|-------------| +| `path` | `str` | Path to the keystore JSON file | + +**Returns:** Parsed keystore dict, or `None` if the file does not exist. + +```python +from lume import load_keystore + +ks = load_keystore() +if ks: + print("Keystore found") +``` + +### `keystore_exists(path: str = DEFAULT_KEYSTORE_PATH) -> bool` + +Check whether a keystore file exists at the given path. + +**Parameters:** +| Name | Type | Description | +|------|------|-------------| +| `path` | `str` | Path to check | + +**Returns:** `True` if the file exists, `False` otherwise. + +```python +from lume import keystore_exists + +if keystore_exists(): + print("Keystore ready") +``` + ### `to_atomic(amount: float | int | str | Decimal) -> int` Convert a human-readable amount to atomic units (6 decimals). diff --git a/skills/lume-trading/references/EXAMPLES.md b/skills/lume-trading/references/EXAMPLES.md index dfbf602..25c5c67 100644 --- a/skills/lume-trading/references/EXAMPLES.md +++ b/skills/lume-trading/references/EXAMPLES.md @@ -29,7 +29,8 @@ print(f"Chain ID: {agent.chain_id}") ```python from lume import create_agent_from_env -# Reads LUME_PRIVATE_KEY (or PRIVATE_KEY), LUME_API_URL, LUME_AGENT_ID, LUME_AGENT_NAME +# If PRIVATE_KEY is set, uses it directly. +# Otherwise, loads or creates an encrypted keystore at ~/.lume/keystore.json agent = create_agent_from_env(agent_id="my-bot", display_name="My Trading Bot") print(f"Agent: {agent.display_name}") @@ -37,6 +38,12 @@ print(f"EOA: {agent.eoa_address}") print(f"Safe: {agent.proxy_wallet}") ``` +To disable keystore and require the env var (old behavior): + +```python +agent = create_agent_from_env(use_keystore=False) +``` + --- ## Check Onboarding Status @@ -100,6 +107,42 @@ print(f"Address: {address}") --- +## Generate and Persist a Wallet with Encrypted Keystore + +```python +from lume import generate_wallet, encrypt_private_key, save_keystore + +private_key, address = generate_wallet() +print(f"Address: {address}") + +# Encrypt and save (prompts for password in create_agent_from_env, or do it manually) +password = "my-secure-password" # In practice, use getpass.getpass() +keystore = encrypt_private_key(private_key, password) +saved_path = save_keystore(keystore) +print(f"Keystore saved: {saved_path}") +``` + +--- + +## Load and Decrypt an Existing Keystore + +```python +from lume import load_keystore, decrypt_private_key, keystore_exists + +if keystore_exists(): + keystore = load_keystore() + password = "my-secure-password" # In practice, use getpass.getpass() + try: + private_key = decrypt_private_key(keystore, password) + print(f"Decrypted key length: {len(private_key)} hex chars") + except ValueError: + print("Wrong password!") +else: + print("No keystore found at default path") +``` + +--- + ## Relay a Gasless Transaction ```python diff --git a/skills/lume-trading/references/TROUBLESHOOTING.md b/skills/lume-trading/references/TROUBLESHOOTING.md index 0b61406..33f687c 100644 --- a/skills/lume-trading/references/TROUBLESHOOTING.md +++ b/skills/lume-trading/references/TROUBLESHOOTING.md @@ -381,3 +381,106 @@ find the user's trading context. This can happen if: -H "Content-Type: application/json" \ -d '{"query":"{ __typename }"}' ``` + +--- + +## ValueError: Failed to decrypt keystore after 3 attempts + +**Symptom:** `create_agent_from_env()` raises `ValueError` after 3 password +prompts. + +**Cause:** The password entered does not match the one used when the keystore +was created. + +**Fix:** + +1. Try again carefully -- passwords are case-sensitive. +2. If you've forgotten the password, delete the keystore and generate a new + wallet: + ```bash + rm ~/.lume/keystore.json + ``` + Then run `create_agent_from_env()` again. Note: this generates a **new** + wallet with a different address. Any funds on the old address are only + recoverable if you know the original password or private key. + +3. If you have the private key backed up elsewhere, set it via env var to + bypass the keystore entirely: + ```bash + export PRIVATE_KEY="your_hex_key" + ``` + +--- + +## ValueError: Passwords do not match + +**Symptom:** When creating a new keystore, the confirmation password does not +match. + +**Cause:** A typo in the password or confirmation prompt. + +**Fix:** Run `create_agent_from_env()` again and enter matching passwords at +both prompts. + +--- + +## Keystore file permission issues + +**Symptom:** `PermissionError` when reading or writing `~/.lume/keystore.json`. + +**Cause:** The keystore file or directory has restrictive permissions set by +another user, or the `~/.lume/` directory does not exist and cannot be created. + +**Fix:** + +1. Check permissions: + ```bash + ls -la ~/.lume/ + ``` + The directory should be mode `700` (`drwx------`) and the file mode `600` + (`-rw-------`), both owned by your user. + +2. Fix permissions if needed: + ```bash + chmod 700 ~/.lume + chmod 600 ~/.lume/keystore.json + ``` + +3. Use a custom path if the default location is not writable: + ```python + agent = create_agent_from_env(keystore_path="/tmp/my-keystore.json") + ``` + Or via environment variable: + ```bash + export LUME_KEYSTORE_PATH="/path/to/keystore.json" + ``` + +--- + +## Agent uses a different address after restart + +**Symptom:** The agent's EOA address changes between runs. + +**Cause:** The agent is generating a new wallet each time instead of reusing an +existing one. This happens if: +- `PRIVATE_KEY` is not set and keystore is disabled (`use_keystore=False`) +- The keystore file was deleted between runs + +**Fix:** + +1. Use the encrypted keystore (default behavior): + ```python + agent = create_agent_from_env() # use_keystore=True by default + ``` + +2. Or set the private key explicitly: + ```bash + export PRIVATE_KEY="your_hex_key" + ``` + +3. Verify the keystore exists: + ```python + from lume import keystore_exists, DEFAULT_KEYSTORE_PATH + print(f"Keystore exists: {keystore_exists()}") + print(f"Path: {DEFAULT_KEYSTORE_PATH}") + ```