diff --git a/CLAUDE.md b/CLAUDE.md index 9611192..38c2981 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,8 @@ alembic upgrade head # apply DB migrations alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env) +PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print the existing master xprv (asks for confirmation first) +PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: bring your own externally-generated xprv instead of generating one (getpass prompt, --overwrite to replace) uvicorn app.main:app --reload --port 8123 # run the dev server diff --git a/docs/setup.md b/docs/setup.md index a69b116..587da7d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -52,6 +52,27 @@ file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza possibilità di recupero. +### Recuperare o portare una xprv esistente + +Due script, entrambi manuali/una tantum, per lo scenario di disaster recovery +o per usare una xprv generata altrove (es. offline/air-gapped) invece di +farla generare al server: + +- **`scripts/decrypt_master_key.py`**: decifra e stampa a schermo la xprv + già presente in `MASTER_KEY_PATH` (con fallback automatico su + `./data/keys/master.xprv.enc` se il path di `.env` non esiste in locale). + Chiede conferma esplicita prima di stampare. +- **`scripts/encrypt_master_key.py`**: cifra una xprv esterna e la scrive in + `MASTER_KEY_PATH` con lo stesso identico schema (Fernet + + `XPRV_ENCRYPTION_KEY`) usato da `generate_master_key.py`. La xprv va + incollata con input nascosto (non appare a schermo). Si rifiuta di + sovrascrivere un file esistente a meno di passare `--overwrite`. + +Entrambi vanno eseguiti localmente (o dentro il container via +`docker compose run --rm app ...`), mai esposti da un endpoint API o dal +pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di +tutti gli utenti e del pool. + ## 4. Installare le dipendenze (solo workflow locale/venv) Salta questo passaggio se usi solo Docker — l'immagine installa le proprie diff --git a/scripts/decrypt_master_key.py b/scripts/decrypt_master_key.py new file mode 100644 index 0000000..47a347c --- /dev/null +++ b/scripts/decrypt_master_key.py @@ -0,0 +1,51 @@ +"""One-time ops recovery: decrypt and print the server's master xprv. + +This is the single secret the entire custodial wallet derives from (every +user address, the pool address) — treat the output like a root password. +Not exposed via any API endpoint or the admin panel, by design; run manually, +locally, only when you actually need it (e.g. disaster-recovery backup). + +Usage: + python scripts/decrypt_master_key.py + +Requires XPRV_ENCRYPTION_KEY to already be set (.env), same as the running +server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path +doesn't exist, falls back to ./data/keys/master.xprv.enc, the location +docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc) +often doesn't match wherever the running server actually reads it from. +""" + +import os + +from app.config import settings +from app.wallet.keystore import decrypt_xprv + +_DOCKER_COMPOSE_PATH = "./data/keys/master.xprv.enc" + + +def _resolve_key_path() -> str: + if os.path.exists(settings.master_key_path): + return settings.master_key_path + if os.path.exists(_DOCKER_COMPOSE_PATH): + print(f"{settings.master_key_path} not found — using {_DOCKER_COMPOSE_PATH} instead.") + return _DOCKER_COMPOSE_PATH + raise SystemExit(f"No master key found at {settings.master_key_path} or {_DOCKER_COMPOSE_PATH}") + + +if __name__ == "__main__": + if not settings.xprv_encryption_key: + raise SystemExit("XPRV_ENCRYPTION_KEY is not set") + + key_path = _resolve_key_path() + + print(f"About to decrypt and print the master xprv from {key_path}.") + print("This key controls every user's funds and the pool. Make sure this") + print("terminal isn't logged/recorded and no one is watching your screen.") + confirm = input("Type 'yes' to continue: ") + if confirm.strip().lower() != "yes": + raise SystemExit("Aborted.") + + with open(key_path, "rb") as f: + token = f.read() + + print(decrypt_xprv(token)) diff --git a/scripts/encrypt_master_key.py b/scripts/encrypt_master_key.py new file mode 100644 index 0000000..c95980c --- /dev/null +++ b/scripts/encrypt_master_key.py @@ -0,0 +1,76 @@ +"""One-time ops bootstrap: encrypt an externally-generated master xprv and +write it to disk, using the exact same scheme generate_master_key.py uses +(Fernet, XPRV_ENCRYPTION_KEY). Use this instead of generate_master_key.py +when you already have an xprv from elsewhere (e.g. generated offline/air- +gapped) and want to bring your own instead of letting the server create one. + +This is the single secret the entire custodial wallet derives from (every +user address, the pool address) — treat both the input and the resulting +encrypted file like a root password. + +Usage: + python scripts/encrypt_master_key.py + python scripts/encrypt_master_key.py --overwrite # replace an existing key + +Requires XPRV_ENCRYPTION_KEY to already be set (.env). Writes to +MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the +docker-compose.yml bind-mount location) if that directory exists and .env's +default path doesn't, same fallback as decrypt_master_key.py. +""" + +import argparse +import getpass +import os + +from embit.bip32 import HDKey + +from app.config import settings +from app.wallet.keystore import encrypt_xprv + +_DOCKER_COMPOSE_DIR = "./data/keys" + + +def _resolve_key_path() -> str: + configured_dir = os.path.dirname(settings.master_key_path) or "." + if os.path.isdir(configured_dir): + return settings.master_key_path + if os.path.isdir(_DOCKER_COMPOSE_DIR): + path = os.path.join(_DOCKER_COMPOSE_DIR, os.path.basename(settings.master_key_path) or "master.xprv.enc") + print(f"{configured_dir}/ not found — using {path} instead.") + return path + return settings.master_key_path + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--overwrite", action="store_true", help="replace an existing encrypted key file") + args = parser.parse_args() + + if not settings.xprv_encryption_key: + raise SystemExit("XPRV_ENCRYPTION_KEY is not set") + + key_path = _resolve_key_path() + if os.path.exists(key_path) and not args.overwrite: + raise SystemExit(f"{key_path} already exists (pass --overwrite to replace it)") + + print("Paste the xprv to encrypt. Input is hidden and not echoed to the terminal.") + xprv = getpass.getpass("xprv: ").strip() + + try: + parsed = HDKey.from_base58(xprv) + except Exception as exc: + raise SystemExit(f"Not a valid extended key: {exc}") from exc + + # Not checking parsed.version against PLM_MAINNET["xprv"]: PLM reuses Bitcoin + # mainnet's own xprv/xpub version bytes (see plm_network.py) — a Bitcoin (or + # any Bitcoin-derived altcoin) xprv passes this check too, so it can't catch + # "wrong network" mistakes. It only tells us this parses as *some* valid + # extended key. + if not parsed.is_private: + raise SystemExit("This is a public key (xpub), not a private key (xprv) — refusing to encrypt it as one.") + + os.makedirs(os.path.dirname(key_path) or ".", exist_ok=True) + with open(key_path, "wb") as f: + f.write(encrypt_xprv(xprv)) + + print(f"Master key written (encrypted) to {key_path}")