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()) == []