- 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>
126 lines
5.0 KiB
Python
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")
|