Files
plm-lottery/app/wallet/verify.py
T
davideandClaude Sonnet 5.5 55d852fe08 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>
2026-10-18 00:38:26 +02:00

126 lines
5.0 KiB
Python

"""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")