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
+50
View File
@@ -353,3 +353,53 @@ async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_
async with payout_session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "pending"
# --- B-28: the draw must not seed itself from an uncorroborated header -----------
class CorroboratingListener:
"""A fake listener whose tip advances the moment a corroboration attempt
fails, simulating a further block arriving — lets tests drive
_wait_for_next_block's retry loop deterministically without real sleeps."""
def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None):
self.tip_height, self.tip_header_hex = next(iter(responses)), "aa"
self._responses = dict(responses)
self._advance_to = advance_to or {}
self.corroboration_calls: list[int] = []
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
self.corroboration_calls.append(height)
result = self._responses[height]
if not result and height in self._advance_to:
self.tip_height, self.tip_header_hex = self._advance_to[height]
return result
async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory):
listener = CorroboratingListener(responses={101: True})
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100)
assert height == 101
assert listener.corroboration_calls == [101]
async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory):
"""B-28: an uncorroborated header must never be used — the wait keeps going
until a later block's header *is* corroborated, logging why each time."""
listener = CorroboratingListener(
responses={101: False, 102: True}, advance_to={101: (102, "bb")}
)
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100)
assert height == 102
assert listener.corroboration_calls == [101, 102]
async with session_factory() as session:
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert events == ["draw_header_corroboration_failed"]