Files
plm-lottery/app/config.py
T
davideandClaude Sonnet 5.5 13cb66cc06 Require a real Electrum quorum and allow certificate pinning (B-84, ELC-01, ELC-03, ELC-14, ELC-19, ARC-20, OPS-12)
An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every
corroboration pass, and with fallbacks the majority was taken over whoever
answered, so one reachable fallback decided alone. TLS certificates were never
checked.

- _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES)
  and a strict majority of the answers; no other server configured, or fewer than
  2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule.
- config.validate_quorum_servers, called from the lifespan after parse_endpoints,
  refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true
  (which logs a warning at every start).
- Optional pinning: `host:port[:mode]:pin=<sha256>` per fallback and
  ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the
  DER certificate after the TLS handshake and a mismatch fails like an unreachable
  server. A pin on a plaintext entry is a startup error.
- .env.example now ships the four bootstrap servers and documents both new
  variables; docs/setup.md, spec, storico and BUGS.md updated.
- Tests changed because the spec changed: the two "no other servers configured ->
  True" corroboration tests now assert that outcome only under allow_no_quorum.

Note: a dev .env with fewer than 2 fallback servers will now refuse to start.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:04:35 +02:00

131 lines
6.0 KiB
Python

import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
# than being shipped by accident.
MIN_JWT_SECRET_LENGTH = 32
# B-84 (ELC-19, ELC-14): fallback servers needed besides the primary. With fewer than
# three servers, a corroboration (which never asks the active one) cannot have two
# independent answers — one server would decide, or none would.
MIN_QUORUM_FALLBACK_SERVERS = 2
MIN_QUORUM_RESPONSES = 2
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
electrum_host: str = "santantonio.sytes.net"
electrum_port: int = 50002
electrum_use_ssl: bool = True
# Additional servers to fall back to, comma-separated `host:port[:notls]`.
# The listener rotates over primary + these (app/electrum/listener.py), so one
# unreachable server costs a single reconnect attempt instead of an outage:
# every deposit credit, broadcast and confirmation goes through this one
# connection, which makes a single hardcoded server the platform's biggest
# single point of failure. Parsed by electrum.client.parse_endpoints.
electrum_fallback_servers: str = ""
# B-84 (ELC-03): SHA-256 (hex) of the primary server's DER certificate. Empty = not
# pinned. Fallback servers carry their own `:pin=<sha256>` in the list above.
electrum_cert_pin: str = ""
# B-84 (ELC-19): development only. Lets the server start with fewer than
# MIN_QUORUM_FALLBACK_SERVERS fallback servers, and corroboration then follows the
# reduced rule (no other server: passes; otherwise a strict majority of however
# many answer, even one). Never for production: it is exactly the single-server
# trust the quorum exists to remove.
allow_no_quorum: bool = False
xprv_encryption_key: str = ""
master_key_path: str = "./master.xprv.enc"
jwt_secret: str = ""
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24
admin_token: str = ""
# Swagger/ReDoc/OpenAPI JSON expose the entire API surface (admin endpoints
# included) to anyone who requests them. Off by default (B-42) — set to true
# only for local development, never in production.
enable_api_docs: bool = False
# Every business/round parameter (bet amount, round duration/cooldown,
# min amount, fee rate, RBF timeout, fee address) lives in the round_config
# DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) —
# editable live via the admin panel/API, no env var, no restart. Only true
# infra/secrets belong in this Settings class.
settings = Settings()
class ConfigError(Exception):
"""A misconfiguration serious enough that the app must refuse to serve."""
def validate_runtime_secrets(config: Settings | None = None) -> None:
"""Fail fast on secrets that would otherwise only break at first use: an empty
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
the container comes up looking healthy and breaks the moment a real user
touches it.
Called from the app's lifespan (app/main.py) rather than as a Settings
field_validator on purpose: Settings is constructed at import time by every
module that reads config, including the test suite, which has no .env and no
business holding real secrets. At startup the guarantee still holds where it
matters — the server refuses to serve half-configured — without coupling every
import to a gitignored file.
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
request when it's empty, so the effect is a locked admin panel, not an open one.
"""
config = config or settings
problems = []
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
problems.append(
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
)
if not config.xprv_encryption_key.strip():
problems.append(
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
)
if problems:
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
def validate_quorum_servers(endpoints: list, config: Settings | None = None) -> None:
"""B-84 (ELC-19): refuse to serve with fewer than MIN_QUORUM_FALLBACK_SERVERS
fallback servers (`endpoints` is the deduplicated rotation: primary first, then the
fallbacks), unless ALLOW_NO_QUORUM=true. The old behaviour — an empty
ELECTRUM_FALLBACK_SERVERS, the .env.example default — silently made every
corroboration pass, i.e. one server decided the draw, the credits and the spends.
Called from the lifespan, after parse_endpoints, rather than from
validate_runtime_secrets: it needs the parsed list, and a Settings validator would
couple every import (tests included) to a populated .env.
"""
config = config or settings
fallbacks = len(endpoints) - 1
if fallbacks >= MIN_QUORUM_FALLBACK_SERVERS:
return
if config.allow_no_quorum:
logger.warning(
"ALLOW_NO_QUORUM=true: only %s fallback Electrum server(s) configured (%s needed) — "
"corroboration is reduced to the weaker rule. Development only, never in production.",
fallbacks,
MIN_QUORUM_FALLBACK_SERVERS,
)
return
raise ConfigError(
f"ELECTRUM_FALLBACK_SERVERS lists {fallbacks} usable fallback server(s) besides the primary, "
f"but at least {MIN_QUORUM_FALLBACK_SERVERS} are required so every corroboration has two independent "
"answers (set ALLOW_NO_QUORUM=true to override, development only)"
)