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

+127
View File
@@ -253,3 +253,130 @@ async def test_a_reply_that_is_not_json_fails_its_request_not_the_connection():
assert await asyncio.wait_for(task2, timeout=1) == 7
client._read_task.cancel()
# --- B-84 (ELC-01, ELC-03): optional certificate pinning --------------------------
_PIN = "ab" * 32
def test_parse_endpoints_reads_pins_on_fallbacks_and_the_primary():
"""ELC-01 / ELC-03 / B-84: `host:port:pin=<sha256>` and `host:port:<mode>:pin=<sha256>`,
plus ELECTRUM_CERT_PIN for the primary; the pin is normalised to lower case."""
from app.electrum.client import ElectrumEndpoint, parse_endpoints
endpoints = parse_endpoints(
"primary.example",
50002,
True,
f"a.example:50002:pin={_PIN.upper()}, b.example:50002:tls:pin={_PIN}, c.example:50001:notls",
primary_pin=_PIN,
)
assert endpoints == [
ElectrumEndpoint("primary.example", 50002, True, _PIN),
ElectrumEndpoint("a.example", 50002, True, _PIN),
ElectrumEndpoint("b.example", 50002, True, _PIN),
ElectrumEndpoint("c.example", 50001, False, None),
]
def test_parse_endpoints_rejects_malformed_pins():
"""ELC-01 / B-84: a bad pin blocks the start, like any malformed entry."""
import pytest
from app.electrum.client import parse_endpoints
for bad in [
"h:1:pin=",
"h:1:pin=abc",
f"h:1:pin={'zz' * 32}",
f"h:1:notls:pin={_PIN}", # nothing to pin without TLS
f"h:1:pin={_PIN}:pin={_PIN}",
f"h:1:tls:ssl:pin={_PIN}",
f"h:1:tls:ssl:notls:pin={_PIN}", # too many fields
]:
with pytest.raises(ValueError):
parse_endpoints("primary.example", 50002, True, bad)
with pytest.raises(ValueError):
parse_endpoints("primary.example", 50002, True, "", primary_pin="nothex")
with pytest.raises(ValueError):
parse_endpoints("primary.example", 50002, False, "", primary_pin=_PIN)
class _FakeSslObject:
def __init__(self, der):
self._der = der
def getpeercert(self, binary_form=False):
assert binary_form
return self._der
class _PinWriter(FakeWriter):
def __init__(self, der):
super().__init__()
self._ssl = _FakeSslObject(der) if der is not None else None
self.closed = False
def get_extra_info(self, name):
return self._ssl if name == "ssl_object" else None
def close(self):
self.closed = True
async def _connect_with_pin(monkeypatch, pin, der):
import hashlib # noqa: F401
writer = _PinWriter(der)
reader = asyncio.StreamReader()
reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n')
async def fake_open_connection(host, port, **kwargs):
return reader, writer
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
client = ElectrumClient("localhost", 1, True, pin)
try:
await client.connect()
finally:
if client._read_task is not None:
client._read_task.cancel()
return writer
async def test_a_connection_with_the_pinned_certificate_is_accepted(monkeypatch):
"""ELC-03 / B-84: the DER certificate hashes to the pin."""
import hashlib
der = b"the server certificate"
writer = await _connect_with_pin(monkeypatch, hashlib.sha256(der).hexdigest().upper(), der)
assert not writer.closed
async def test_a_connection_with_another_certificate_fails_like_an_unreachable_server(monkeypatch):
"""ELC-03 / B-84: mismatch (and no certificate at all) raise ElectrumError and close."""
import hashlib
import pytest
pin = hashlib.sha256(b"the pinned certificate").hexdigest()
for der in (b"someone else in the middle", None):
writer = _PinWriter(der)
async def fake_open_connection(host, port, writer=writer, **kwargs):
return asyncio.StreamReader(), writer
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
with pytest.raises(ElectrumError):
await ElectrumClient("localhost", 1, True, pin).connect()
assert writer.closed
async def test_main_builds_clients_with_the_endpoint_pin():
"""ELC-03 / B-84: app.main hands each endpoint's pin to its client."""
from app.electrum.client import ElectrumEndpoint
from app.main import _make_electrum_client
assert _make_electrum_client(ElectrumEndpoint("h", 1, True, _PIN)).pin == _PIN
assert _make_electrum_client(ElectrumEndpoint("h", 1, True)).pin is None