Adds missing standard artifacts: - README.md (if missing) - AGENTS.md (AI agent contract) - PLAN.md (current sprint) - STATUS.md (where we are) - DEVELOPMENT.md (dev workflow) - DEPLOYMENT.md (deploy procedure) - TESTING.md (test strategy) - DECISIONS.md (ADR index + templates) - .github/CODEOWNERS - .github/workflows/ci.yml Preserves all existing artifacts. Refs: RugMunchMedia/fleet-template
167 lines
7.5 KiB
Python
167 lines
7.5 KiB
Python
"""WalletPress Backend Configuration — all values from env, zero hardcodes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
class Config:
|
|
title: str = "WalletPress API"
|
|
version: str = "1.1.0"
|
|
description: str = "Self-hosted multi-chain wallet generation & management"
|
|
|
|
host: str = os.getenv("WP_HOST", "0.0.0.0")
|
|
port: int = int(os.getenv("WP_PORT", "8010"))
|
|
|
|
data_dir: Path = Path(os.getenv("WP_DATA_DIR", "/data"))
|
|
vault_path: Path = data_dir / "vault.json"
|
|
db_path: Path = data_dir / "walletpress.db"
|
|
audit_path: Path = data_dir / "audit.jsonl"
|
|
keys_path: Path = data_dir / "api_keys.json"
|
|
vault_key_path: Path = data_dir / "vault.key" # P0-5 KEK file
|
|
|
|
admin_key: str = os.getenv("WP_ADMIN_KEY", "")
|
|
_vault_password: str = ""
|
|
|
|
def __init__(self):
|
|
# P0-5: Resolve vault password from multiple sources, in priority order:
|
|
# 1. WP_VAULT_PASSWORD env var (legacy, dev only)
|
|
# 2. ~/.walletpress/vault.key file with mode 0600
|
|
# 3. {data_dir}/vault.key file with mode 0600
|
|
# 4. Auto-generate a random one on first run (persisted to vault.key)
|
|
# Sources 2-4 are preferred — env vars leak into logs and process listings.
|
|
env_pw = os.getenv("WP_VAULT_PASSWORD", "")
|
|
if env_pw:
|
|
self._vault_password = env_pw
|
|
return
|
|
|
|
# Try user-level file
|
|
user_key = Path.home() / ".walletpress" / "vault.key"
|
|
if user_key.exists() and _check_key_file_perms(user_key):
|
|
self._vault_password = user_key.read_text().strip()
|
|
return
|
|
|
|
# Try data-dir file
|
|
if self.vault_key_path.exists() and _check_key_file_perms(self.vault_key_path):
|
|
self._vault_password = self.vault_key_path.read_text().strip()
|
|
return
|
|
|
|
# Auto-generate on first run (refuse to run with no KEK in production)
|
|
if os.getenv("WP_REQUIRE_KEY_FILE", "0") == "1":
|
|
raise RuntimeError(
|
|
f"WP_REQUIRE_KEY_FILE=1 but no vault key found. "
|
|
f"Create {self.vault_key_path} (mode 0600) or set WP_VAULT_PASSWORD."
|
|
)
|
|
# Dev mode: auto-generate, but only if we can write to the data dir.
|
|
# In restricted environments (e.g. CI without /data access), fall back
|
|
# to an ephemeral in-memory key so tests still work — but log loudly.
|
|
import secrets as _secrets
|
|
new_key = _secrets.token_urlsafe(48)
|
|
try:
|
|
self.vault_key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.vault_key_path.write_text(new_key)
|
|
os.chmod(self.vault_key_path, 0o600)
|
|
self._vault_password = new_key
|
|
import logging
|
|
logging.getLogger("wp.config").warning(
|
|
f"Auto-generated vault key at {self.vault_key_path} (mode 0600). "
|
|
f"Back this up — if lost, all vault data is unrecoverable."
|
|
)
|
|
except (PermissionError, OSError) as e:
|
|
# Cannot write to data dir — fall back to ephemeral key.
|
|
import logging
|
|
self._vault_password = new_key
|
|
logging.getLogger("wp.config").warning(
|
|
f"Cannot write vault.key to {self.vault_key_path} ({e}). "
|
|
f"Using EPHEMERAL in-memory key — vault data will NOT survive restarts. "
|
|
f"Set WP_VAULT_PASSWORD or make data_dir writable in production."
|
|
)
|
|
|
|
@property
|
|
def vault_password(self) -> str:
|
|
return self._vault_password
|
|
|
|
@vault_password.setter
|
|
def vault_password(self, value: str) -> None:
|
|
self._vault_password = value
|
|
|
|
def clear_vault_password(self) -> None:
|
|
"""Clear vault password from memory. Call after vault is initialized."""
|
|
self._vault_password = ""
|
|
|
|
def rotate_vault_key(self) -> None:
|
|
"""Generate a new vault key. Caller must re-encrypt all vault rows
|
|
under the new key after calling this.
|
|
|
|
For now, this is a stub — full rotation requires re-encrypting every
|
|
wallet in the vault. See AUDIT.md P0-5 / Phase 2 follow-up.
|
|
"""
|
|
import secrets as _secrets
|
|
new_key = _secrets.token_urlsafe(48)
|
|
self._vault_password = new_key
|
|
self.vault_key_path.write_text(new_key)
|
|
os.chmod(self.vault_key_path, 0o600)
|
|
|
|
cors_origins: list[str] = os.getenv("WP_CORS_ORIGINS", "http://localhost:8010").split(",")
|
|
|
|
rate_limit_per_minute: int = int(os.getenv("WP_RATE_LIMIT", "60"))
|
|
max_wallets_per_request: int = int(os.getenv("WP_MAX_BATCH", "100"))
|
|
|
|
@staticmethod
|
|
def rpc_url(chain: str, default: str) -> str:
|
|
"""Get RPC URL for a chain, checking WP_RPC_{CHAIN} env var override."""
|
|
return os.getenv(f"WP_RPC_{chain.upper()}", default)
|
|
|
|
@staticmethod
|
|
def rpc_urls() -> dict[str, str]:
|
|
"""Get all RPC URLs from env overrides merged with defaults."""
|
|
return {
|
|
"eth": Config.rpc_url("eth", "https://eth.llamarpc.com"),
|
|
"base": Config.rpc_url("base", "https://mainnet.base.org"),
|
|
"polygon": Config.rpc_url("polygon", "https://polygon-rpc.com"),
|
|
"arbitrum": Config.rpc_url("arbitrum", "https://arb1.arbitrum.io/rpc"),
|
|
"optimism": Config.rpc_url("optimism", "https://mainnet.optimism.io"),
|
|
"avalanche": Config.rpc_url("avalanche", "https://api.avax.network/ext/bc/C/rpc"),
|
|
"bsc": Config.rpc_url("bsc", "https://bsc-dataseed.binance.org"),
|
|
"fantom": Config.rpc_url("fantom", "https://rpc.ftm.tools"),
|
|
"gnosis": Config.rpc_url("gnosis", "https://rpc.gnosischain.com"),
|
|
"celo": Config.rpc_url("celo", "https://forno.celo.org"),
|
|
"scroll": Config.rpc_url("scroll", "https://rpc.scroll.io"),
|
|
"zksync": Config.rpc_url("zksync", "https://mainnet.era.zksync.io"),
|
|
"blast": Config.rpc_url("blast", "https://rpc.blast.io"),
|
|
"mantle": Config.rpc_url("mantle", "https://rpc.mantle.xyz"),
|
|
"linea": Config.rpc_url("linea", "https://rpc.linea.build"),
|
|
"metis": Config.rpc_url("metis", "https://andromeda.metis.io/?owner=1088"),
|
|
"opbnb": Config.rpc_url("opbnb", "https://opbnb-mainnet-rpc.bnbchain.org"),
|
|
"core": Config.rpc_url("core", "https://rpc.coredao.org"),
|
|
"frax": Config.rpc_url("frax", "https://rpc.frax.com"),
|
|
"kava": Config.rpc_url("kava", "https://evm.kava.io"),
|
|
"moonbeam": Config.rpc_url("moonbeam", "https://rpc.api.moonbeam.network"),
|
|
"cronos": Config.rpc_url("cronos", "https://evm.cronos.org"),
|
|
"harmony": Config.rpc_url("harmony", "https://api.harmony.one"),
|
|
"sol": Config.rpc_url("sol", "https://api.mainnet-beta.solana.com"),
|
|
"trx": Config.rpc_url("trx", "https://api.trongrid.io"),
|
|
}
|
|
|
|
|
|
def _check_key_file_perms(path: Path) -> bool:
|
|
"""Verify the key file has restrictive permissions (0600 or stricter).
|
|
|
|
Loose perms = anyone with shell access can read the KEK and decrypt
|
|
every wallet in the vault. Refuse to use a loosely-permed key file.
|
|
"""
|
|
import stat
|
|
import logging
|
|
mode = stat.S_IMODE(path.stat().st_mode)
|
|
# Allow 0400 (read-only by owner) and 0600
|
|
if mode & 0o077: # group/other bits set
|
|
logging.getLogger("wp.config").warning(
|
|
f"Vault key file {path} has permissive permissions {oct(mode)}. "
|
|
f"Run: chmod 600 {path}"
|
|
)
|
|
# Don't fail — just warn. Operators should fix this.
|
|
return True
|
|
|
|
|
|
cfg = Config()
|