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:
+41
-10
@@ -1,19 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from app.rounds.draw import draw_winner, header_hex_to_block_hash
|
||||
from app.rounds.draw import (
|
||||
draw_winner,
|
||||
header_hex_to_block_hash,
|
||||
header_meets_its_own_target,
|
||||
header_prev_hash,
|
||||
)
|
||||
|
||||
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
|
||||
# cross-checked against the blockhash reported by blockchain.transaction.get for a
|
||||
# tx confirmed in that block.
|
||||
_REAL_HEADER_HEX = (
|
||||
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
|
||||
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
|
||||
"e6a47a61b1ad8012582"
|
||||
)
|
||||
|
||||
|
||||
def test_header_hex_to_block_hash_matches_known_mainnet_block():
|
||||
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
|
||||
# cross-checked against the blockhash reported by blockchain.transaction.get
|
||||
# for a tx confirmed in that block.
|
||||
header_hex = (
|
||||
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
|
||||
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
|
||||
"e6a47a61b1ad8012582"
|
||||
)
|
||||
known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86"
|
||||
assert header_hex_to_block_hash(header_hex) == known_block_hash
|
||||
assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash
|
||||
|
||||
|
||||
def test_header_meets_its_own_target_accepts_a_real_mined_header():
|
||||
"""B-28: a genuinely mined mainnet header must pass its own self-consistency
|
||||
check — this isn't just a synthetic-header property."""
|
||||
assert header_meets_its_own_target(_REAL_HEADER_HEX) is True
|
||||
|
||||
|
||||
def test_header_meets_its_own_target_rejects_a_tampered_header():
|
||||
"""Flipping a single nonce bit changes the hash completely (avalanche effect)
|
||||
without changing the claimed difficulty, so a tampered-but-otherwise-real
|
||||
header should almost certainly fail — this is what would catch a
|
||||
hostile/MITM'd server replaying a real header with a doctored field."""
|
||||
tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX))
|
||||
tampered[-1] ^= 0xFF # flip the last byte of the nonce
|
||||
assert header_meets_its_own_target(tampered.hex()) is False
|
||||
|
||||
|
||||
def test_header_meets_its_own_target_rejects_wrong_length():
|
||||
assert header_meets_its_own_target("aa" * 10) is False
|
||||
|
||||
|
||||
def test_header_prev_hash_matches_the_known_previous_block():
|
||||
# Block 477486's predecessor, 477485 — independently known from the same chain.
|
||||
assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb"
|
||||
|
||||
|
||||
def test_draw_winner_is_deterministic_and_within_range():
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Listener-level behaviour: server rotation on failure (the fallback-servers
|
||||
feature), and the chain-tip monotonicity guard (B-19).
|
||||
feature), the chain-tip monotonicity guard (B-19), header validation and
|
||||
multi-server corroboration (B-28).
|
||||
|
||||
The reconnect loop itself (B-01) is covered from the client side in
|
||||
test_electrum_client.py — what's asserted here is that the listener *acts* on a
|
||||
@@ -7,6 +8,7 @@ dead connection by moving to the next server instead of retrying the same one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
@@ -14,6 +16,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from app.db.base import Base
|
||||
from app.electrum.client import ElectrumEndpoint
|
||||
from app.electrum.listener import ElectrumListener
|
||||
from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -31,6 +34,34 @@ _ENDPOINTS = [
|
||||
ElectrumEndpoint("third.example", 50001, False),
|
||||
]
|
||||
|
||||
# A regtest-style trivial difficulty target (~50% of hashes satisfy it), so mining
|
||||
# a real, self-consistent test header takes a handful of nonce attempts rather than
|
||||
# needing actual mainnet-grade hashpower. Not a valid PLM mainnet difficulty —
|
||||
# irrelevant here, since header_meets_its_own_target only checks self-consistency.
|
||||
_EASY_BITS = 0x207FFFFF
|
||||
|
||||
|
||||
def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str:
|
||||
return (
|
||||
struct.pack("<I", 1) # version
|
||||
+ bytes.fromhex(prev_hash_hex)[::-1]
|
||||
+ bytes.fromhex("00" * 32) # merkle_root, irrelevant to the checks under test
|
||||
+ struct.pack("<I", 0) # timestamp
|
||||
+ struct.pack("<I", bits)
|
||||
+ struct.pack("<I", nonce)
|
||||
).hex()
|
||||
|
||||
|
||||
def _mine_header(prev_hash_hex: str, *, bits: int = _EASY_BITS) -> str:
|
||||
"""A real header that satisfies its own claimed target — good enough to
|
||||
exercise header_meets_its_own_target/_apply_header for real, without needing
|
||||
genuine PLM-mainnet-grade hashpower."""
|
||||
for nonce in range(100_000):
|
||||
header_hex = _build_header(prev_hash_hex, nonce, bits=bits)
|
||||
if header_meets_its_own_target(header_hex):
|
||||
return header_hex
|
||||
raise RuntimeError("failed to mine a test header within the attempt budget")
|
||||
|
||||
|
||||
async def test_rotates_to_the_next_server_after_a_failed_session(session_factory):
|
||||
"""One unreachable server should cost a single attempt, not an outage: every
|
||||
@@ -132,11 +163,144 @@ def test_tip_never_moves_backwards(session_factory):
|
||||
a stale one."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
|
||||
listener._apply_header({"height": 100, "hex": "aa"})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, "aa")
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
|
||||
|
||||
listener._apply_header({"height": 99, "hex": "bb"}) # reorg, or a server switch
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, "aa")
|
||||
# A lower height is ignored purely on height, before any header validation even
|
||||
# runs — reorg or server switch, not a real advance.
|
||||
listener._apply_header({"height": 99, "hex": "bb"})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
|
||||
|
||||
listener._apply_header({"height": 101, "hex": "cc"})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (101, "cc")
|
||||
header_101 = _mine_header(header_hex_to_block_hash(header_100))
|
||||
listener._apply_header({"height": 101, "hex": header_101})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (101, header_101)
|
||||
|
||||
|
||||
# --- B-28: a hostile or MITM'd server can no longer single-handedly decide the
|
||||
# draw's entropy — header self-consistency/linkage checks, and multi-server
|
||||
# corroboration for the block the draw actually uses. ---------------------------
|
||||
|
||||
|
||||
def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory):
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
# Real mainnet-grade difficulty (genesis-era Bitcoin bits): satisfying it by
|
||||
# chance is astronomically unlikely, so this header is self-inconsistent.
|
||||
forged = _build_header("00" * 32, nonce=0, bits=0x1D00FFFF)
|
||||
|
||||
with pytest.raises(HeaderValidationError):
|
||||
listener._apply_header({"height": 100, "hex": forged})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (0, None) # untouched
|
||||
|
||||
|
||||
def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_factory):
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
# A single-block advance (101 = 100 + 1) whose prev_block claims an unrelated
|
||||
# chain — well-formed and self-consistently mined, but not actually built on
|
||||
# top of our current tip.
|
||||
disconnected = _mine_header("ff" * 32)
|
||||
|
||||
with pytest.raises(HeaderValidationError):
|
||||
listener._apply_header({"height": 101, "hex": disconnected})
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
|
||||
|
||||
|
||||
def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory):
|
||||
"""A reconnect (or the very first header of a session) hands us whatever the
|
||||
server's current tip is — which is legitimately not a single-block advance
|
||||
from whatever we last saw. There's no full header chain to check linkage
|
||||
against in that case, so only self-consistency is enforced."""
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
|
||||
header_100 = _mine_header("00" * 32)
|
||||
listener._apply_header({"height": 100, "hex": header_100})
|
||||
|
||||
header_150 = _mine_header("ff" * 32) # unrelated prev_block, height jumps by 50
|
||||
listener._apply_header({"height": 150, "hex": header_150}) # must not raise
|
||||
|
||||
assert (listener.tip_height, listener.tip_header_hex) == (150, header_150)
|
||||
|
||||
|
||||
async def _endpoint_client_factory(responses: dict[str, object]):
|
||||
"""Builds a client_factory whose fake clients answer blockchain.block.header
|
||||
per-endpoint according to `responses`: a header hex string to agree/disagree
|
||||
with, `None` to simulate an unreachable server, or an Exception instance to
|
||||
simulate a request failure."""
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, answer):
|
||||
self._answer = answer
|
||||
self.closed = False
|
||||
|
||||
async def connect(self):
|
||||
if isinstance(self._answer, Exception):
|
||||
raise self._answer
|
||||
|
||||
async def request(self, method, params):
|
||||
assert method == "blockchain.block.header"
|
||||
if self._answer is None:
|
||||
raise ConnectionRefusedError("unreachable")
|
||||
return self._answer
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def factory(endpoint):
|
||||
return _FakeClient(responses[endpoint.host])
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def test_corroborate_header_true_with_no_other_servers_configured(session_factory):
|
||||
single = [ElectrumEndpoint("only.example", 50002, True)]
|
||||
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
|
||||
assert await listener.corroborate_header(100, "deadbeef") is True
|
||||
|
||||
|
||||
async def test_corroborate_header_true_when_others_agree(session_factory):
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": header_hex, "second.example": header_hex, "third.example": header_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory):
|
||||
"""The active connection is exactly what a hostile server or a MITM would
|
||||
control — corroborating against it too would defeat the point."""
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
# first.example (the active endpoint) would raise if ever queried.
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
assert listener.current_endpoint.host == "first.example"
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is True
|
||||
|
||||
|
||||
async def test_corroborate_header_false_when_majority_disagrees(session_factory):
|
||||
header_hex = _mine_header("00" * 32)
|
||||
expected_hash = header_hex_to_block_hash(header_hex)
|
||||
disagreeing_hex = _mine_header("11" * 32)
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, expected_hash) is False
|
||||
|
||||
|
||||
async def test_corroborate_header_false_when_nobody_responds(session_factory):
|
||||
factory = await _endpoint_client_factory(
|
||||
{"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")}
|
||||
)
|
||||
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
|
||||
|
||||
assert await listener.corroborate_header(100, "deadbeef") is False
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user