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") def model_post_init(self, __context) -> None: if self.xprv_encryption_key_file and not self.xprv_encryption_key: try: with open(self.xprv_encryption_key_file) as f: self.xprv_encryption_key = f.read().strip() except OSError: pass # validate_runtime_secrets reports it, naming the file 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=` 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 # B-90 (OPS-27): start with the lottery paused, as after restoring a backup. start_paused: bool = False xprv_encryption_key: str = "" # B-91 (SEC-53): the key as a file (a compose secret in the container), so it is in no # environment variable and `docker inspect` cannot show it. When set it is read into # `xprv_encryption_key` at load, which is what the rest of the code uses. xprv_encryption_key_file: 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(): where = ( f"XPRV_ENCRYPTION_KEY_FILE ({config.xprv_encryption_key_file}) is missing or empty" if config.xprv_encryption_key_file else "XPRV_ENCRYPTION_KEY_FILE must point to a file holding the key" ) problems.append( where + " (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)" )