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>
This commit is contained in:
1 parent
fd6a91e93d
commit
13cb66cc06
18 files changed
+451
-125
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
|
||||
@@ -5,6 +7,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# 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")
|
||||
@@ -21,6 +31,15 @@ class Settings(BaseSettings):
|
||||
# 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"
|
||||
@@ -79,3 +98,33 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
|
||||
)
|
||||
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)"
|
||||
)
|
||||
+78
-17
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
@@ -13,50 +14,91 @@ logger = logging.getLogger(__name__)
|
||||
class ElectrumEndpoint(NamedTuple):
|
||||
"""One server to connect to. The listener rotates over a list of these so a
|
||||
single unreachable or misbehaving server doesn't take the platform down —
|
||||
every consumer of PLM chain data goes through this one connection."""
|
||||
every consumer of PLM chain data goes through this one connection.
|
||||
|
||||
`pin` (B-84, ELC-03) is the SHA-256 of the server's DER certificate, in lower-case
|
||||
hex; when set, a connection presenting any other certificate fails like an
|
||||
unreachable server. Only meaningful with TLS."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
use_ssl: bool = True
|
||||
pin: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}"
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}{' (pinned)' if self.pin else ''}"
|
||||
|
||||
|
||||
_PIN_PATTERN = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
def normalize_pin(raw: str, entry: str) -> str:
|
||||
"""A certificate pin as lower-case hex, or ValueError: 64 hexadecimal digits."""
|
||||
pin = raw.strip().lower()
|
||||
if not _PIN_PATTERN.fullmatch(pin):
|
||||
raise ValueError(f"invalid certificate pin in Electrum server {entry!r}: expected 64 hex digits (sha256)")
|
||||
return pin
|
||||
|
||||
|
||||
def parse_endpoints(
|
||||
primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str
|
||||
primary_host: str,
|
||||
primary_port: int,
|
||||
primary_use_ssl: bool,
|
||||
fallback_spec: str,
|
||||
primary_pin: str = "",
|
||||
) -> list[ElectrumEndpoint]:
|
||||
"""Build the connection rotation: the primary server first, then whatever
|
||||
ELECTRUM_FALLBACK_SERVERS lists.
|
||||
|
||||
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
|
||||
case) or `host:port:notls`. Malformed entries raise ValueError rather than
|
||||
being skipped: a typo in a fallback server is something to fix at startup,
|
||||
not to discover during an outage, when the fallback is what's needed.
|
||||
case), `host:port:notls`, `host:port:pin=<sha256>` (TLS with a pinned certificate,
|
||||
B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=<sha256>`. A pin on a
|
||||
plaintext entry is refused: there is no certificate to pin. `primary_pin` is the
|
||||
same pin for the primary (ELECTRUM_CERT_PIN). Malformed entries raise ValueError
|
||||
rather than being skipped: a typo in a fallback server is something to fix at
|
||||
startup, not to discover during an outage, when the fallback is what's needed.
|
||||
Duplicates are dropped, keeping first position.
|
||||
"""
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)]
|
||||
syntax = "expected host:port[:notls][:pin=<sha256>]"
|
||||
pin = normalize_pin(primary_pin, "ELECTRUM_CERT_PIN") if primary_pin.strip() else None
|
||||
if pin and not primary_use_ssl:
|
||||
raise ValueError("ELECTRUM_CERT_PIN needs TLS: ELECTRUM_USE_SSL is off")
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl, pin)]
|
||||
for raw in fallback_spec.split(","):
|
||||
entry = raw.strip()
|
||||
if not entry:
|
||||
continue
|
||||
parts = entry.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
if not 2 <= len(parts) <= 4:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
|
||||
host, port = parts[0].strip(), parts[1].strip()
|
||||
if not host or not port.isdigit():
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
|
||||
use_ssl = True
|
||||
if len(parts) == 3:
|
||||
flag = parts[2].strip().lower()
|
||||
if flag not in ("ssl", "tls", "notls", "plain"):
|
||||
pin = None
|
||||
mode_seen = False
|
||||
for extra in parts[2:]:
|
||||
extra = extra.strip()
|
||||
if extra.lower().startswith("pin="):
|
||||
if pin is not None:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: more than one pin")
|
||||
pin = normalize_pin(extra[4:], entry)
|
||||
continue
|
||||
flag = extra.lower()
|
||||
if mode_seen or flag not in ("ssl", "tls", "notls", "plain"):
|
||||
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
|
||||
mode_seen = True
|
||||
use_ssl = flag in ("ssl", "tls")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl))
|
||||
if pin and not use_ssl:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: a pin needs TLS")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl, pin))
|
||||
|
||||
deduped: list[ElectrumEndpoint] = []
|
||||
seen: set[tuple] = set()
|
||||
for endpoint in endpoints:
|
||||
if endpoint not in deduped:
|
||||
key = (endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(endpoint)
|
||||
return deduped
|
||||
|
||||
@@ -100,10 +142,11 @@ class ElectrumClient:
|
||||
notification queues nobody will ever fill again.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True):
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True, pin: str | None = None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.use_ssl = use_ssl
|
||||
self.pin = pin.lower() if pin else None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._id_counter = itertools.count(1)
|
||||
@@ -115,7 +158,10 @@ class ElectrumClient:
|
||||
async def connect(self) -> None:
|
||||
# Electrum servers commonly present self-signed certs; the protocol's trust
|
||||
# model is server consensus, not TLS PKI, so we only use SSL for transport
|
||||
# encryption and don't verify the certificate chain/hostname.
|
||||
# encryption and don't verify the certificate chain/hostname. What can be
|
||||
# asked instead is that the certificate be *the* one the operator pinned
|
||||
# (B-84, ELC-03): without a pin, whoever sits on the path to a server takes
|
||||
# its place, and only the quorum stands between that and the draw.
|
||||
ssl_context = None
|
||||
if self.use_ssl:
|
||||
ssl_context = ssl.create_default_context()
|
||||
@@ -124,9 +170,24 @@ class ElectrumClient:
|
||||
self._reader, self._writer = await asyncio.open_connection(
|
||||
self.host, self.port, ssl=ssl_context, limit=_MAX_LINE_BYTES
|
||||
)
|
||||
if self.pin:
|
||||
try:
|
||||
self._check_pin()
|
||||
except ElectrumError:
|
||||
self._writer.close()
|
||||
raise
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
await self.request("server.version", ["plm-lottery", "1.4"])
|
||||
|
||||
def _check_pin(self) -> None:
|
||||
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
|
||||
ssl_object = self._writer.get_extra_info("ssl_object") if self._writer is not None else None
|
||||
der = ssl_object.getpeercert(binary_form=True) if ssl_object is not None else None
|
||||
if not der:
|
||||
raise ElectrumError(f"{self.host}:{self.port} presented no certificate to check against the pin")
|
||||
if hashlib.sha256(der).hexdigest() != self.pin:
|
||||
raise ElectrumError(f"{self.host}:{self.port} certificate does not match the configured pin")
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed.set()
|
||||
if self._read_task is not None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.deposits.service import (
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.draw import (
|
||||
@@ -63,10 +64,13 @@ class ElectrumListener:
|
||||
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
|
||||
session_factory: async_sessionmaker,
|
||||
endpoints: list[ElectrumEndpoint] | None = None,
|
||||
allow_no_quorum: bool = False,
|
||||
):
|
||||
self._client_factory = client_factory
|
||||
self._session_factory = session_factory
|
||||
self._endpoints = list(endpoints or [])
|
||||
# B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority.
|
||||
self._allow_no_quorum = allow_no_quorum
|
||||
self._endpoint_index = 0
|
||||
self._scripthash_to_user: dict[str, int] = {}
|
||||
# Retains address_for_new_user's fire-and-forget subscribe task so it
|
||||
@@ -363,21 +367,36 @@ class ElectrumListener:
|
||||
would control) and require a strict majority of the ones that actually
|
||||
answer to agree, via `agrees`, with what our own connection reported.
|
||||
|
||||
Returns True with no other servers configured — nothing to corroborate
|
||||
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
|
||||
(see CLAUDE.md). Returns False (never silently "passes") if none of the
|
||||
others could be reached, since an unreachable network proves nothing
|
||||
either way.
|
||||
The quorum is strict (B-84, ELC-14): it passes only when **at least
|
||||
MIN_QUORUM_RESPONSES** of the other servers answered *and* a strict majority of
|
||||
those that answered agree. Fewer answers — one reachable server, or none, or no
|
||||
other server configured at all — fail, since a lone voice decides nothing
|
||||
independently and an unreachable network proves nothing. Startup already refuses
|
||||
to run with fewer than two fallbacks (config.validate_quorum_servers), so with a
|
||||
sound configuration "too few answered" means servers are down, and the
|
||||
corroboration simply fails (a delay, never a loss — the callers retry).
|
||||
|
||||
Only with ALLOW_NO_QUORUM=true (development) does the reduced rule apply: no
|
||||
other server configured passes, and otherwise a strict majority of whoever
|
||||
answered decides, even a single answer.
|
||||
"""
|
||||
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
if not others:
|
||||
if not others and self._allow_no_quorum:
|
||||
return True
|
||||
if not others:
|
||||
logger.warning("could not corroborate %s: no other Electrum server is configured", description)
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
|
||||
responded = [result for result in results if result is not None]
|
||||
if not responded:
|
||||
minimum = 1 if self._allow_no_quorum else MIN_QUORUM_RESPONSES
|
||||
if len(responded) < minimum:
|
||||
logger.warning(
|
||||
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
|
||||
"could not corroborate %s: %s of %s other configured server(s) answered, %s needed",
|
||||
description,
|
||||
len(responded),
|
||||
len(others),
|
||||
minimum,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
+7
-3
@@ -22,7 +22,7 @@ from app.api.routes.users import router as users_router
|
||||
from app.api.routes.withdrawals import router as withdrawals_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.api.errors import ApiError
|
||||
from app.config import settings, validate_runtime_secrets
|
||||
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.deposits.reconcile import DepositReconciler
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
|
||||
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -50,10 +50,14 @@ async def lifespan(app: FastAPI):
|
||||
settings.electrum_port,
|
||||
settings.electrum_use_ssl,
|
||||
settings.electrum_fallback_servers,
|
||||
settings.electrum_cert_pin,
|
||||
)
|
||||
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
||||
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
||||
|
||||
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints)
|
||||
listener = ElectrumListener(
|
||||
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
|
||||
)
|
||||
app.state.electrum_listener = listener
|
||||
app.state.user_locks = UserLocks()
|
||||
|
||||
|
||||
Reference in new issue
Block a user