Add ops scripts to decrypt/re-encrypt the master xprv, and document them
decrypt_master_key.py: prints the existing master xprv after an explicit confirmation prompt, for disaster-recovery backups. Falls back to ./data/keys/master.xprv.enc (the docker-compose.yml bind-mount path) when .env's configured MASTER_KEY_PATH doesn't exist locally. encrypt_master_key.py: the reverse direction — takes an externally-generated xprv (e.g. created offline/air-gapped) via a hidden getpass prompt, validates it parses as a private extended key, and encrypts it with the same Fernet scheme generate_master_key.py uses. Refuses to overwrite an existing key file unless --overwrite is passed. Neither script is reachable via any API endpoint or the admin panel, by design — this is the one secret the entire custodial wallet derives from. Documented in docs/setup.md (new "Recuperare o portare una xprv esistente" section) and CLAUDE.md's Commands block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user