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
+90 -4
View File
@@ -338,9 +338,11 @@ async def _endpoint_client_factory(responses: dict[str, object]):
return factory
async def test_corroborate_header_true_with_no_other_servers_configured(session_factory):
async def test_corroborate_header_true_with_no_other_servers_only_under_allow_no_quorum(session_factory):
"""ELC-14 / ELC-19 / B-84: this used to be the rule for everyone. Now only the
development override passes with nothing to corroborate against."""
single = [ElectrumEndpoint("only.example", 50002, True)]
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True)
assert await listener.corroborate_header(100, "deadbeef") is True
@@ -424,9 +426,10 @@ async def _listunspent_client_factory(responses: dict[str, object]):
return factory
async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory):
async def test_corroborate_utxo_spent_true_with_no_other_servers_only_under_allow_no_quorum(session_factory):
"""ELC-14 / ELC-19 / B-84, see the header twin of this test."""
single = [ElectrumEndpoint("only.example", 50002, True)]
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True)
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
@@ -842,3 +845,86 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 25_000_000
# --- B-84 (ELC-14, ELC-19): the quorum is never one server, and never nobody ------
async def test_corroboration_fails_with_no_other_server_configured(session_factory):
"""ELC-14 / B-84: nothing to corroborate against is a failure, not a pass."""
single = [ElectrumEndpoint("only.example", 50002, True)]
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
assert await listener.corroborate_header(100, "deadbeef") is False
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
async def test_a_single_responding_server_does_not_decide(session_factory):
"""ELC-14 / B-84: two others are configured but only one answers — even though it
agrees, one answer is not a quorum. Before B-84 this passed."""
header_hex = _mine_header("00" * 32)
expected_hash = header_hex_to_block_hash(header_hex)
factory = await _endpoint_client_factory(
{"first.example": "irrelevant", "second.example": header_hex, "third.example": None}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_header(100, expected_hash) is False
# The development override restores the reduced rule.
relaxed = ElectrumListener(factory, session_factory, _ENDPOINTS, allow_no_quorum=True)
assert await relaxed.corroborate_header(100, expected_hash) is True
async def test_two_answers_must_both_agree_for_a_strict_majority(session_factory):
"""ELC-14 / B-84: with exactly two answers, 1 agree + 1 disagree is not a majority."""
header_hex = _mine_header("00" * 32)
expected_hash = header_hex_to_block_hash(header_hex)
other_hex = _mine_header("11" * 32)
factory = await _endpoint_client_factory(
{"first.example": "irrelevant", "second.example": header_hex, "third.example": other_hex}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_header(100, expected_hash) is False
async def test_a_credit_is_not_corroborated_by_one_server_alone(session_factory):
"""ELC-17 / B-84: the same minimum applies to every kind of corroboration."""
entry = {"tx_hash": "77" * 32, "tx_pos": 0, "value": 5_000_000, "height": 10}
factory = await _listunspent_client_factory(
{"first.example": [entry], "second.example": [entry], "third.example": None}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False
def test_startup_refuses_fewer_than_two_fallback_servers():
"""ELC-19 / B-84: refuse to serve unless ALLOW_NO_QUORUM=true."""
import pytest
from app.config import ConfigError, Settings, validate_quorum_servers
primary = ElectrumEndpoint("primary.example", 50002, True)
one = [primary, ElectrumEndpoint("a.example", 50002, True)]
two = one + [ElectrumEndpoint("b.example", 50002, True)]
for too_few in ([primary], one):
with pytest.raises(ConfigError, match="ELECTRUM_FALLBACK_SERVERS"):
validate_quorum_servers(too_few, Settings(allow_no_quorum=False))
validate_quorum_servers(two, Settings(allow_no_quorum=False)) # must not raise
validate_quorum_servers([primary], Settings(allow_no_quorum=True)) # the override
async def test_the_lifespan_refuses_to_serve_without_a_quorum(monkeypatch):
"""ELC-19 / ARC-60 / B-84: app.main.lifespan stops before starting any task."""
import pytest
from fastapi import FastAPI
from app.config import ConfigError, settings
from app.main import lifespan
monkeypatch.setattr(settings, "jwt_secret", "x" * 40)
monkeypatch.setattr(settings, "xprv_encryption_key", "k")
monkeypatch.setattr(settings, "electrum_fallback_servers", "only-one.example:50002")
monkeypatch.setattr(settings, "allow_no_quorum", False)
with pytest.raises(ConfigError):
async with lifespan(FastAPI()):
pass