From 55d852fe0823c7f40e4839ac2dfd4f2dd6b178bc Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 18 Oct 2026 00:38:26 +0200 Subject: [PATCH] Verify the master key against the database at startup (B-89, SEC-49, SEC-50, SEC-09) 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 --- BUGS.md | 10 --- CLAUDE.md | 4 +- README.md | 2 +- app/main.py | 2 + app/wallet/hd.py | 5 +- app/wallet/keystore.py | 11 +++ app/wallet/verify.py | 125 ++++++++++++++++++++++++++++ docs/architettura.md | 7 +- docs/operativita.md | 7 +- docs/sicurezza.md | 22 ++--- docs/storico-bug.md | 4 +- scripts/encrypt_master_key.py | 12 ++- scripts/generate_master_key.py | 5 ++ tests/unit/test_master_key_check.py | 111 ++++++++++++++++++++++++ 14 files changed, 281 insertions(+), 46 deletions(-) create mode 100644 app/wallet/verify.py create mode 100644 tests/unit/test_master_key_check.py diff --git a/BUGS.md b/BUGS.md index bcb803b..5ab7c01 100644 --- a/BUGS.md +++ b/BUGS.md @@ -102,16 +102,6 @@ decisions, see `docs/limiti-noti.md`): ## High -### B-89 — Key problems surface only at first use - -Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty; -a wrong key or a different xprv is found at the first registration or payout, and a -different xprv silently moves the pool address. The scripts accept any xprv and write -files `0644`. - -Fix: at startup decrypt the key and check the pool reserve address and a sample of -stored user addresses; refuse to serve on mismatch. The scripts refuse an xprv whose -derived addresses differ from the DB's and write `0600`. ### B-90 — Restoring a backup is unsafe diff --git a/CLAUDE.md b/CLAUDE.md index c6b7a8e..81ca827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 702 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 711 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 702 tests +python -m pytest # all 711 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` diff --git a/README.md b/README.md index c33ab10..93af724 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -702 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +711 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/main.py b/app/main.py index 682c201..1ad7331 100644 --- a/app/main.py +++ b/app/main.py @@ -32,6 +32,7 @@ from app.tx.broadcast import RbfBumper from app.tx.confirmation import ConfirmationPoller from app.tx.locks import UserLocks from app.tx.reconcile import PendingTransactionReconciler +from app.wallet.verify import verify_master_key logger = logging.getLogger(__name__) @@ -53,6 +54,7 @@ async def lifespan(app: FastAPI): settings.electrum_cert_pin, ) validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum + verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints)) listener = ElectrumListener( diff --git a/app/wallet/hd.py b/app/wallet/hd.py index 28703a7..aac926b 100644 --- a/app/wallet/hd.py +++ b/app/wallet/hd.py @@ -5,7 +5,7 @@ from embit.bip32 import HDKey from embit.ec import PrivateKey from app.config import settings -from app.wallet.keystore import decrypt_xprv, encrypt_xprv +from app.wallet.keystore import decrypt_xprv, encrypt_xprv, write_secret_file from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET _account_key: HDKey | None = None @@ -17,8 +17,7 @@ def generate_master_key(overwrite: bool = False) -> None: if os.path.exists(settings.master_key_path) and not overwrite: raise FileExistsError(f"{settings.master_key_path} already exists") root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"]) - with open(settings.master_key_path, "wb") as f: - f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"]))) + write_secret_file(settings.master_key_path, encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"]))) def _load_account_key() -> HDKey: diff --git a/app/wallet/keystore.py b/app/wallet/keystore.py index f6d517b..a304b17 100644 --- a/app/wallet/keystore.py +++ b/app/wallet/keystore.py @@ -1,3 +1,5 @@ +import os + from cryptography.fernet import Fernet from app.config import settings @@ -20,3 +22,12 @@ def encrypt_round_seed(server_seed_hex: str) -> str: def decrypt_round_seed(token: str) -> str: return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode() + + +def write_secret_file(path: str, data: bytes) -> None: + """B-89 (SEC-50): created `0600` from the start (never through a wider mode) and + rewritten with the same mode, so a key file is not readable by other users.""" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as f: + os.fchmod(f.fileno(), 0o600) + f.write(data) diff --git a/app/wallet/verify.py b/app/wallet/verify.py new file mode 100644 index 0000000..0e5235e --- /dev/null +++ b/app/wallet/verify.py @@ -0,0 +1,125 @@ +"""B-89 (SEC-49, SEC-50): is the master key the one that produced what the database holds? + +A different xprv, or a wrong encryption key, used to surface only at the first +registration or payout — and a different xprv never did: it quietly moved the pool +address. The check compares a fixed sample of stored addresses with what the key derives, +reading the database with plain `sqlite3` (read-only), so the startup check and the +key scripts share it without an event loop.""" + +import hashlib +import logging +import sqlite3 +from typing import NamedTuple + +from cryptography.fernet import Fernet +from embit import script +from embit.bip32 import HDKey + +from app.config import ConfigError, settings +from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET + +logger = logging.getLogger(__name__) + +_SAMPLE_SIZE = 10 + + +class Sample(NamedTuple): + users: list[tuple[int, str]] # (derivation_index, address) + rounds: list[tuple[int, str]] # (id, pool_address) of the last rounds with one + seeds: list[tuple[int, str, str]] # (id, server_seed_enc, server_seed_hash), unrevealed + + +def database_file(url: str | None = None) -> str | None: + """The SQLite file `DATABASE_URL` points at, or None for anything else.""" + url = url or settings.database_url + prefix = "sqlite+aiosqlite:///" + return url[len(prefix):] if url.startswith(prefix) else None + + +def load_sample(path: str | None = None) -> Sample: + """The lowest and highest `_SAMPLE_SIZE` users, the last rounds that have a pool + address and every round whose secret is still unrevealed. A missing file or missing + tables (a fresh instance) is an empty sample: there is nothing to compare.""" + path = path or database_file() + empty = Sample([], [], []) + if path is None: + return empty + try: + db = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + except sqlite3.Error: + return empty + try: + users = { + index: address + for order in ("ASC", "DESC") + for index, address in db.execute( + f"SELECT derivation_index, address FROM users ORDER BY derivation_index {order} LIMIT ?", + (_SAMPLE_SIZE,), + ) + } + rounds = db.execute( + "SELECT id, pool_address FROM rounds WHERE pool_address != '' ORDER BY id DESC LIMIT ?", + (_SAMPLE_SIZE,), + ).fetchall() + seeds = db.execute( + "SELECT id, server_seed_enc, server_seed_hash FROM rounds " + "WHERE server_seed_enc IS NOT NULL AND server_seed IS NULL" + ).fetchall() + return Sample(sorted(users.items()), rounds, seeds) + except sqlite3.OperationalError: # no such table + return empty + finally: + db.close() + + +def account_key(xprv_base58: str) -> HDKey: + return HDKey.from_base58(xprv_base58).derive(ACCOUNT_PATH) + + +def _address(account: HDKey, path: str) -> str: + return script.p2wpkh(account.derive(path).to_public()).address(network=PLM_MAINNET) + + +def sample_problems(account: HDKey, sample: Sample, encryption_key: str | None = None) -> list[str]: + """One line per check that failed; never any key material. With `encryption_key` + the unrevealed round secrets are checked as well (they depend on it, not on the xprv).""" + problems = [] + for index, stored in sample.users: + if _address(account, f"0/{index}") != stored: + problems.append(f"user address at derivation index {index} differs from the one derived") + if sample.rounds: + reserve = _address(account, "1/0") + for round_id, stored in sample.rounds: + if stored not in (reserve, _address(account, f"2/{round_id}")): + problems.append(f"pool address of round {round_id} is neither its own nor the reserve address") + if encryption_key is not None: + fernet = Fernet(encryption_key) + for round_id, token, committed in sample.seeds: + try: + secret = fernet.decrypt(token.encode()).decode() + except Exception: + problems.append(f"secret of round {round_id} does not decrypt") + continue + if hashlib.sha256(bytes.fromhex(secret)).hexdigest() != committed: + problems.append(f"secret of round {round_id} does not match its published hash") + return problems + + +def verify_master_key() -> None: + """Called from the lifespan, before any task starts: decrypt the key and compare the + sample. Raises `ConfigError` (the process exits) on the first sign of a mismatch.""" + from app.wallet import hd + + try: + account = hd._load_account_key() + except Exception as exc: + raise ConfigError( + f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): " + "check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY" + ) from exc + problems = sample_problems(account, load_sample(), settings.xprv_encryption_key) + if problems: + raise ConfigError( + "the master key does not match the database — refusing to serve: " + "; ".join(problems) + ) + logger.info("Master key verified against the database sample") diff --git a/docs/architettura.md b/docs/architettura.md index 9dc5360..bef3584 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -343,7 +343,7 @@ sequenceDiagram L->>L: validate_runtime_secrets() L->>L: parse_endpoints(...) L->>L: quorum minimo (validate_quorum_servers) - L->>L: verifica della master key (B-89, da implementare) + L->>L: verifica della master key (B-89) L->>L: START_PAUSED (B-90, da implementare) L->>L: app.state.electrum_listener, app.state.user_locks L->>L: create_task ×6 @@ -448,9 +448,8 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). ([operativita.md](operativita.md#ripristino), B-90). *Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` · *Test:* `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo - i segreti, gli endpoint e il quorum il lifespan crea subito i task; la - chiave si decifra alla prima registrazione o al primo pagamento. + *Stato:* da implementare (B-90), per il controllo 3 · *Oggi:* il lifespan non + ferma la registrazione né mette in pausa dopo un ripristino. - **[ARC-21]** Lo stato condiviso con le route vive su `app.state`: `electrum_listener` (l'`ElectrumListener`) e `user_locks` (un `UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato diff --git a/docs/operativita.md b/docs/operativita.md index 163ac7e..a8ea2ff 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -69,9 +69,6 @@ dovranno allora rispettare quel proprietario. `app/config.py:validate_runtime_secrets`, `app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` · *Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py` - *Stato:* da implementare (B-89) · *Oggi:* della master key si controlla - solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; una - chiave sbagliata emerge alla prima derivazione. - **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a `plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le @@ -754,7 +751,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). copia cifrata con essa: la copia nella radice (`./master.xprv.enc`, B-71) e i backup precedenti del file. Il file scritto dal container appartiene a root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`. - Dopo B-89 gli script scrivono `0600` e rifiutano una xprv i cui + Gli script scrivono `0600` e rifiutano una xprv i cui indirizzi non coincidono con quelli del database ([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non in `.env` ([SEC-53](sicurezza.md#sec-53)). @@ -923,7 +920,7 @@ master key usare il container. `generate_master_key.py` non ha il ripiego su `./data/keys/` degli altri due: dal venv scrive esattamente dove dice `MASTER_KEY_PATH` di `.env`. I file scritti hanno i permessi predefiniti del sistema (di norma `0644`) e, - nel container, appartengono a root. Dopo B-89 gli script scrivono `0600` e + nel container, appartengono a root. Gli script scrivono `0600` e rifiutano una xprv diversa da quella degli indirizzi già nel database ([SEC-50](sicurezza.md#sec-50)). *Codice:* `scripts/generate_master_key.py`, `app/wallet/hd.py:generate_master_key`, diff --git a/docs/sicurezza.md b/docs/sicurezza.md index e7ea9e6..2e29650 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -182,10 +182,7 @@ flowchart TD `0600` (senza passare da permessi più larghi) e lo riscrivono con gli stessi permessi. *Codice:* `scripts/generate_master_key.py`, `scripts/encrypt_master_key.py`, - `app/wallet/hd.py:generate_master_key` · *Test:* — - *Stato:* da implementare (B-89) · *Oggi:* gli script accettano qualunque - xprv senza guardare il database e scrivono il file con i permessi - predefiniti del sistema (di norma `0644`). + `app/wallet/hd.py:generate_master_key`, `app/wallet/verify.py` · *Test:* `tests/unit/test_master_key_check.py` ### Cifratura a riposo @@ -237,10 +234,8 @@ sì che un backup dei dati non contenga entrambe. verificata ([SEC-49]). Da quel momento la chiave dell'account `m/84'/746'/0'` resta in memoria nel processo per tutta la sua durata; la root xprv non viene conservata. - *Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan` - *Stato:* da implementare (B-89) · *Oggi:* la chiave è decifrata al primo - uso (la prima derivazione, tipicamente una registrazione o il primo - pagamento), non all'avvio. + *Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan` · + *Test:* `tests/unit/test_master_key_check.py` ### Recupero @@ -299,14 +294,9 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi decifri. Lo scopo è non partire con una master key diversa da quella che ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il montepremi e farebbe firmare transazioni che la rete rifiuta. - *Codice:* `app/main.py:lifespan`, `app/wallet/hd.py:_load_account_key`, - `app/wallet/hd.py:derive_user_address`, `app/wallet/hd.py:derive_pool_address` · - *Test:* — - *Stato:* da implementare (B-89) · *Oggi:* all'avvio non si decifra nulla; - una chiave di cifratura sbagliata o malformata emerge solo al primo uso - della master key (prima registrazione o primo pagamento), e una xprv - diversa da quella originale non emerge affatto: produce indirizzi nuovi, - compreso quello del montepremi. + *Codice:* `app/main.py:lifespan`, `app/wallet/verify.py:verify_master_key`, + `app/wallet/verify.py:sample_problems` · + *Test:* `tests/unit/test_master_key_check.py` ## Autenticazione degli utenti diff --git a/docs/storico-bug.md b/docs/storico-bug.md index f4d6e34..1e303be 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -237,7 +237,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | *da registrare* | `test_electrum_listener.py` | ELC, OPS | | B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | *da registrare* | `test_rbf_dust.py`, `test_broadcast.py` | TX | | B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC | -| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | **aperto** | — | SEC, OPS | +| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS | | B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | **aperto** | — | OPS, REG | | B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS | | B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API | @@ -254,7 +254,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). - **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-95, B-98, B-99, B-100. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-95, B-98, B-99, B-100. - **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici, 3 alti, 5 medi), da implementare nell'ordine di diff --git a/scripts/encrypt_master_key.py b/scripts/encrypt_master_key.py index c95980c..59590a7 100644 --- a/scripts/encrypt_master_key.py +++ b/scripts/encrypt_master_key.py @@ -25,7 +25,8 @@ import os from embit.bip32 import HDKey from app.config import settings -from app.wallet.keystore import encrypt_xprv +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" @@ -69,8 +70,13 @@ if __name__ == "__main__": 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) - with open(key_path, "wb") as f: - f.write(encrypt_xprv(xprv)) + write_secret_file(key_path, encrypt_xprv(xprv)) print(f"Master key written (encrypted) to {key_path}") diff --git a/scripts/generate_master_key.py b/scripts/generate_master_key.py index 4b9e3f1..705b8fc 100644 --- a/scripts/generate_master_key.py +++ b/scripts/generate_master_key.py @@ -8,9 +8,14 @@ Usage: from app.config import settings from app.wallet.hd import generate_master_key +from app.wallet.verify import load_sample if __name__ == "__main__": if not settings.xprv_encryption_key: raise SystemExit("XPRV_ENCRYPTION_KEY is not set") + sample = load_sample() + if sample.users or sample.rounds or sample.seeds: + # SEC-50: a new key cannot reproduce the addresses the database already holds. + raise SystemExit("the database already has users or rounds: a new key would not reproduce their addresses") generate_master_key() print(f"Master key written (encrypted) to {settings.master_key_path}") diff --git a/tests/unit/test_master_key_check.py b/tests/unit/test_master_key_check.py new file mode 100644 index 0000000..a4a9d00 --- /dev/null +++ b/tests/unit/test_master_key_check.py @@ -0,0 +1,111 @@ +"""B-89 (SEC-49, SEC-50, SEC-09): the master key must be the one that produced the database.""" + +import hashlib +import os +import sqlite3 +import stat + +import pytest +from cryptography.fernet import Fernet +from embit import script + +from app.config import ConfigError, settings +from app.wallet import hd +from app.wallet.keystore import encrypt_round_seed +from app.wallet.plm_network import PLM_MAINNET +from app.wallet.verify import load_sample, sample_problems, verify_master_key + + +def _database(tmp_path, monkeypatch, users=(), rounds=()): + """A file DB with just the columns the check reads.""" + path = tmp_path / "check.db" + db = sqlite3.connect(path) + db.execute("CREATE TABLE users (derivation_index INTEGER, address TEXT)") + db.execute( + "CREATE TABLE rounds (id INTEGER, pool_address TEXT, server_seed_enc TEXT, server_seed TEXT, server_seed_hash TEXT)" + ) + db.executemany("INSERT INTO users VALUES (?, ?)", users) + db.executemany("INSERT INTO rounds VALUES (?, ?, ?, ?, ?)", rounds) + db.commit() + db.close() + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{path}") + + +def _addr(path): + return script.p2wpkh(hd._load_account_key().derive(path).to_public()).address(network=PLM_MAINNET) + + +def test_a_fresh_instance_only_needs_the_key_to_decrypt(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/absent.db") + verify_master_key() + + +def test_the_matching_key_passes(tmp_path, monkeypatch): + _database( + tmp_path, + monkeypatch, + users=[(i, _addr(f"0/{i}")) for i in range(25)], + rounds=[(1, _addr("1/0"), None, None, None), (2, _addr("2/2"), None, None, None)], + ) + verify_master_key() + + +def test_another_master_key_is_refused_without_printing_it(tmp_path, monkeypatch): + """SEC-49: the rows came from a different xprv (the two-keys incident).""" + _database(tmp_path, monkeypatch, users=[(0, _addr("0/0")), (1, _addr("0/1"))]) + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "other.xprv.enc")) + hd._account_key = None + hd.generate_master_key() + with pytest.raises(ConfigError, match="derivation index 0") as excinfo: + verify_master_key() + assert "xprv" not in str(excinfo.value).lower().replace("master key", "") + + +def test_only_the_edges_of_the_user_table_are_sampled(tmp_path, monkeypatch): + users = [(i, _addr(f"0/{i}")) for i in range(40)] + _database(tmp_path, monkeypatch, users=users) + assert [i for i, _ in load_sample().users] == list(range(10)) + list(range(30, 40)) + + +def test_a_round_pool_address_must_be_its_own_or_the_reserve(tmp_path, monkeypatch): + _database(tmp_path, monkeypatch, rounds=[(5, _addr("2/9"), None, None, None)]) + with pytest.raises(ConfigError, match="round 5"): + verify_master_key() + + +def test_a_wrong_encryption_key_is_refused_at_startup(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + hd._account_key = None + with pytest.raises(ConfigError, match="cannot be loaded"): + verify_master_key() + + +def test_an_unrevealed_secret_must_decrypt_and_match_its_hash(tmp_path, monkeypatch): + secret = "ab" * 32 + good = (1, "", encrypt_round_seed(secret), None, hashlib.sha256(bytes.fromhex(secret)).hexdigest()) + bad_hash = (2, "", encrypt_round_seed(secret), None, "00" * 32) + revealed = (3, "", "not-a-token", secret, "00" * 32) # already revealed: not checked + _database(tmp_path, monkeypatch, rounds=[good, bad_hash, revealed]) + problems = sample_problems(hd._load_account_key(), load_sample(), settings.xprv_encryption_key) + assert problems == ["secret of round 2 does not match its published hash"] + + +def test_the_key_file_is_written_0600(tmp_path, monkeypatch): + path = tmp_path / "k.xprv.enc" + monkeypatch.setattr(settings, "master_key_path", str(path)) + os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o644)) # a leftover world-readable file + hd.generate_master_key(overwrite=True) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_the_import_script_refuses_an_xprv_that_does_not_match_the_database(tmp_path, monkeypatch): + """SEC-05 / SEC-50: a Bitcoin-looking xprv passes the version check, the addresses do not.""" + from embit.bip32 import HDKey + + from app.wallet.verify import account_key + + _database(tmp_path, monkeypatch, users=[(0, _addr("0/0"))]) + other = HDKey.from_seed(b"\x09" * 32, version=PLM_MAINNET["xprv"]).to_base58(version=PLM_MAINNET["xprv"]) + assert sample_problems(account_key(other), load_sample()) + mine = hd._load_account_key() + assert sample_problems(mine, load_sample()) == []