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:
+67
View File
@@ -1,5 +1,22 @@
import hashlib
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
_HEADER_LENGTH_BYTES = 80
_PREV_BLOCK_OFFSET = 4
_PREV_BLOCK_LENGTH = 32
_BITS_OFFSET = 72
_BITS_LENGTH = 4
class HeaderValidationError(Exception):
"""Raised by ElectrumListener._apply_header (B-28) when a header either doesn't
satisfy the difficulty target it claims for itself, or doesn't chain from the
previously accepted tip. Letting this propagate out of the header-consuming
task ends the current Electrum session the same way a dropped connection would
(see ElectrumListener._run_once), so the listener rotates to the next
configured server instead of trusting a header a server just forged."""
def header_hex_to_block_hash(header_hex: str) -> str:
"""Block hash from a raw Electrum header: sha256d, byte-reversed, hex.
@@ -10,6 +27,56 @@ def header_hex_to_block_hash(header_hex: str) -> str:
return digest[::-1].hex()
def header_prev_hash(header_hex: str) -> str:
"""The header's `prev_block` field, byte-reversed to the same conventional
(display) order as header_hex_to_block_hash's return value, so the two can be
compared directly to check that one header actually chains from another."""
header_bytes = bytes.fromhex(header_hex)
prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH]
return prev[::-1].hex()
def _target_from_bits(bits: int) -> int:
"""Decompress Bitcoin-style compact `nBits` difficulty encoding into the full
256-bit target a valid header's hash must be less than or equal to."""
exponent = bits >> 24
mantissa = bits & 0xFFFFFF
if exponent <= 3:
return mantissa >> (8 * (3 - exponent))
return mantissa << (8 * (exponent - 3))
def header_meets_its_own_target(header_hex: str) -> bool:
"""Whether this header's hash satisfies the difficulty target *it claims for
itself* (the `bits` field). Rejects a header that was never actually mined —
e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28),
since satisfying a self-chosen target still requires real proof-of-work.
This does NOT — and, short of downloading and validating the full header
chain's difficulty-retarget history, cannot — catch a header honestly mined at
a real but implausibly low self-chosen difficulty: a server could still declare
an easy target and grind it out with modest hardware. That residual risk is why
the draw additionally requires the winning block's header to be corroborated by
the *other* configured servers before using it as the seed (see
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
rather than relying on this check alone.
"""
header_bytes = bytes.fromhex(header_hex)
if len(header_bytes) != _HEADER_LENGTH_BYTES:
return False
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
target = _target_from_bits(bits)
if target <= 0:
return False
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
# The hash as the integer comparable against `target`: this is the same digest
# header_hex_to_block_hash reverses into the conventional display hex, so
# reading it byte-reversed as a big-endian int is equivalent to reading the
# original digest bytes as little-endian — both give the same integer.
hash_int = int.from_bytes(digest, "little")
return hash_int <= target
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
"""v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer,
index = seed mod participant_count, winner = participants[index]. Anyone can
+33 -5
View File
@@ -142,7 +142,7 @@ class RoundScheduler:
broadcaster.publish()
tip_at_close = self._listener.tip_height
block_height, block_hash = await self._wait_for_next_block(tip_at_close)
block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close)
winner_address = draw_winner(addresses, block_hash)
async with self._session_factory() as session:
@@ -172,10 +172,38 @@ class RoundScheduler:
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
await self._trigger_payout(round_id)
async def _wait_for_next_block(self, tip_at_close: int) -> tuple[int, str]:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
async def _wait_for_next_block(self, round_id: int, tip_at_close: int) -> tuple[int, str]:
"""Waits for a block after tip_at_close and, before handing it back as the
draw's entropy source, requires it to be corroborated by the other
configured Electrum servers (B-28) — our own active connection is exactly
the thing a hostile server or a MITM would control, so its header alone is
not enough to seed a payout. A candidate that fails corroboration is never
used: this keeps waiting for a further block and tries corroborating that
one instead, logging why every time so a stuck draw is visible in
/admin's audit log rather than a silent, unexplained wait."""
while True:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
height = self._listener.tip_height
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
if await self._listener.corroborate_header(height, block_hash):
return height, block_hash
logger.error(
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
"waiting for a further block",
round_id,
height,
block_hash,
)
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_header_corroboration_failed",
{"height": height, "reported_hash": block_hash},
round_id=round_id,
)
await session.commit()
tip_at_close = height
async def _retry_payout_if_due(self, round_id: int) -> None:
"""B-26: whether a "paying_out" round is due for another payout attempt.