Files
plm-lottery/app/wallet/verify.py
T
davideandClaude Sonnet 5.5 c1b192f6d1 Harden the container: non-root, pinned images and dependencies, the key in a file (B-91, SEC-51, SEC-52, SEC-53; closes B-71, B-72)
- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it
  and closes it to others (works on an older root-owned install with no host sudo);
  files are created 0600 (umask 077)
- python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from
  requirements.lock with --require-hashes
- the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose
  secret), out of every environment variable and of a ./data backup
- setup.md no longer presents a second way to run the server (B-72)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 00:54:23 +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_FILE"
) 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")