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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 00:38:26 +02:00
1 parent 32df50cb75
commit 55d852fe08
14 files changed
+281 -46

No files matched your search

-10
View File
@@ -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
+2 -2
View File
@@ -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
```
+1 -1
View File
@@ -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
+2
View File
@@ -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(
+2 -3
View File
@@ -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:
+11
View File
@@ -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)
+125
View File
@@ -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")
+3 -4
View File
@@ -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
+2 -5
View File
@@ -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`,
+6 -16
View File
@@ -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
+2 -2
View File
@@ -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
+9 -3
View File
@@ -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}")
+5
View File
@@ -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}")
+111
View File
@@ -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()) == []