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:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:04:35 +02:00
1 parent fd6a91e93d
commit 13cb66cc06
18 files changed
+451 -125

No files matched your search

+78 -17
View File
@@ -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:
+27 -8
View File
@@ -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