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
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user