The lifespan decrypts the key and compares a fixed sample (reserve/round pool addresses, the 10 lowest and highest user addresses, unrevealed round secrets) with the database, and refuses to serve on any mismatch without printing key material. The key scripts refuse a new or imported key that would not reproduce the stored addresses, and write 0600. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
83 lines
3.4 KiB
Python
83 lines
3.4 KiB
Python
"""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, write_secret_file
|
|
from app.wallet.verify import account_key, load_sample, sample_problems
|
|
|
|
_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.")
|
|
|
|
# SEC-05/SEC-50: the version bytes cannot tell a PLM key from a Bitcoin one, the stored
|
|
# addresses can. A mismatch is refused even with --overwrite.
|
|
problems = sample_problems(account_key(xprv), load_sample())
|
|
if problems:
|
|
raise SystemExit("this xprv does not match the database — refusing to write it: " + "; ".join(problems))
|
|
|
|
os.makedirs(os.path.dirname(key_path) or ".", exist_ok=True)
|
|
write_secret_file(key_path, encrypt_xprv(xprv))
|
|
|
|
print(f"Master key written (encrypted) to {key_path}")
|