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: