Validate Electrum headers and corroborate the draw's block (B-28)

A single hostile Electrum server, or a MITM on the one active
connection, could fabricate the block header the draw's entropy comes
from and so pick the winner of every round: headers were accepted with
no proof-of-work check and no link to the previous tip.

app/rounds/draw.py adds header_meets_its_own_target (rejects a header
whose hash doesn't satisfy the difficulty target it claims) and
header_prev_hash. electrum/listener.py's _apply_header now rejects a
header failing either check by raising HeaderValidationError, which
ends the session the same way a dropped connection would so the
listener rotates to the next configured server.

ElectrumListener gains corroborate_header: before the draw uses a
block, it's independently checked against the other configured servers
and needs a majority to agree. rounds/scheduler.py's
_wait_for_next_block now calls this and, on failure, logs why and
waits for a further block instead of ever using an uncorroborated
header.

Certificate/hostname verification stays disabled, so this doesn't
cover an attacker able to MITM every configured server at once -
BUGS.md notes that as not covered.

Suite grows from 151 to 165 tests. BUGS.md moves B-28 to Previously
fixed.
This commit is contained in:
2026-07-27 10:07:21 +02:00
parent 7224ca0e66
commit 0ce0562fd7
7 changed files with 463 additions and 63 deletions
+89 -1
View File
@@ -9,6 +9,12 @@ from app.db.models import User
from app.deposits.service import credit_confirmed_utxos, detect_external_spends
from app.electrum.client import ElectrumClient, ElectrumEndpoint
from app.electrum.scripthash import address_to_scripthash
from app.rounds.draw import (
HeaderValidationError,
header_hex_to_block_hash,
header_meets_its_own_target,
header_prev_hash,
)
from app.rounds.events import broadcaster
logger = logging.getLogger(__name__)
@@ -18,6 +24,12 @@ logger = logging.getLogger(__name__)
# difference between noticing the drop in a minute and never noticing it at all.
_PING_INTERVAL_SECONDS = 60
# B-28: how long to wait for any *one* other server's answer when corroborating the
# draw's block header. Shorter than the standard request timeout since this is a
# supplementary check across several servers at once — a single slow fallback
# shouldn't hold up the others.
_CORROBORATION_TIMEOUT_SECONDS = 10
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
@@ -173,8 +185,19 @@ class ElectrumListener:
the draw's wait. height and hex are applied together or not at all —
applying a losing header's hex would leave tip_height and tip_header_hex
describing different blocks, and that hex is the draw's entropy source.
Two validation checks guard against a hostile or MITM'd server simply
fabricating a header (B-28), since that header is the draw's sole source of
entropy: it must satisfy the difficulty target it claims for itself, and —
when it's a direct single-block advance from our own current tip, the only
case we can check without a full header chain — it must chain from that
tip's hash. Either failure raises HeaderValidationError rather than
silently ignoring the header, which (via _consume_headers/_run_once) ends
this session the same way a dropped connection would, so run() rotates to
the next configured server instead of continuing to trust this one.
"""
height = header["height"]
header_hex = header.get("hex")
if height < self.tip_height:
logger.warning(
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
@@ -182,8 +205,73 @@ class ElectrumListener:
self.tip_height,
)
return
if header_hex:
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError(
f"header at height {height} does not satisfy its own claimed difficulty target"
)
if (
self.tip_header_hex
and height == self.tip_height + 1
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
):
raise HeaderValidationError(
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
)
self.tip_height = height
self.tip_header_hex = header.get("hex")
self.tip_header_hex = header_hex
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
"""B-28: independently ask every *other* configured server for the header
at `height` and require a strict majority of the ones that actually answer
to agree with `expected_hash` — the hash our own active connection
reported — before the draw (rounds/scheduler.py:_wait_for_next_block) treats
it as trustworthy entropy. Without this, a single hostile server (or a MITM
on the one active connection) can single-handedly decide who wins every
round; this raises the bar to controlling a majority of the configured
servers.
Returns True if there are no other servers configured at all — a
single-endpoint deployment has nothing to corroborate against, and accepted
that risk when ELECTRUM_FALLBACK_SERVERS was left empty (see CLAUDE.md).
Also returns False (never silently "passes") if none of the other servers
could be reached at all, since an unreachable network answers nothing about
whether the header is genuine.
"""
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
if not others:
return True
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
result = await asyncio.wait_for(
client.request("blockchain.block.header", [height]),
timeout=_CORROBORATION_TIMEOUT_SECONDS,
)
if not isinstance(result, str):
return None
return header_hex_to_block_hash(result)
except Exception:
return None
finally:
await client.close()
results = await asyncio.gather(*(_ask(endpoint) for endpoint in others))
responded = [block_hash for block_hash in results if block_hash is not None]
if not responded:
logger.warning(
"could not corroborate block %s header with any of %s other configured server(s)",
height,
len(others),
)
return False
agreements = sum(1 for block_hash in responded if block_hash == expected_hash)
return agreements * 2 > len(responded)
async def _consume_headers(self, queue: asyncio.Queue) -> None:
while True: