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