Refuse an Electrum server that is not on the PLM chain (B-99, ELC-30)
PLM shares Bitcoin's genesis and powLimit, so a Bitcoin server passed every header check. connect() now asks server.features and requires PLM's genesis. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1433813ec7
commit
2c0e71a74c
4 files changed
+72
-2
No files matched your search
@@ -7,6 +7,8 @@ import re
|
||||
import ssl
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.wallet.plm_network import GENESIS_HASH
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -192,6 +194,20 @@ class ElectrumClient:
|
||||
raise
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
await self.request("server.version", ["plm-lottery", "1.4"])
|
||||
try:
|
||||
await self._check_genesis()
|
||||
except ElectrumError:
|
||||
self._read_task.cancel()
|
||||
self._writer.close()
|
||||
raise
|
||||
|
||||
async def _check_genesis(self) -> None:
|
||||
"""The server must be on PLM's chain, not just any chain with valid headers: PLM shares
|
||||
Bitcoin's genesis and powLimit, so only `server.features` tells them apart (B-99, ELC-30)."""
|
||||
features = await self.request("server.features")
|
||||
genesis = features.get("genesis_hash") if isinstance(features, dict) else None
|
||||
if genesis != GENESIS_HASH:
|
||||
raise ElectrumError(f"{self.host}:{self.port} is not on the PLM chain (genesis {genesis!r})")
|
||||
|
||||
def _check_pin(self) -> None:
|
||||
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
|
||||
|
||||
@@ -26,3 +26,7 @@ PLM_MAINNET = {
|
||||
|
||||
BIP44_COIN_TYPE = 746
|
||||
ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h"
|
||||
|
||||
# PLM shares Bitcoin's genesis block (palladiumcore chainparams.cpp) and its powLimit, so a
|
||||
# Bitcoin Electrum server would pass every header check; the handshake asks for this instead.
|
||||
GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
|
||||
@@ -62,6 +62,16 @@ Le difese sono cinque.
|
||||
di [ELC-14].
|
||||
*Codice:* `app/config.py:validate_quorum_servers`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
- **[ELC-30]** Subito dopo `server.version` la connessione chiede
|
||||
`server.features`: il `genesis_hash` deve essere quello di PLM
|
||||
(`000000000019d668…ce26f`). PLM condivide genesis e `powLimit` con Bitcoin
|
||||
(`palladiumcore/src/chainparams.cpp`), quindi un server Electrum di Bitcoin
|
||||
supererebbe ogni controllo sugli header di [ELC-11]; solo il genesis lo
|
||||
distingue. Un genesis diverso o assente, o un errore alla richiesta, fa
|
||||
fallire la connessione come un server irraggiungibile ([ELC-02]); vale anche
|
||||
per le connessioni del quorum ([ELC-14]).
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient._check_genesis`, `app/wallet/plm_network.py:GENESIS_HASH` ·
|
||||
*Test:* `tests/unit/test_electrum_client.py`
|
||||
|
||||
## 2. Richieste con limite di tempo
|
||||
|
||||
@@ -338,11 +348,14 @@ B-83).
|
||||
- **[ELC-29]** Il listener espone lo stato della connessione: se è connesso,
|
||||
il server attivo, l'altezza della punta e da quanti secondi è stato
|
||||
accettato l'ultimo header. Lo leggono `GET /health/ready` (non pronto se non
|
||||
connesso o se l'ultimo header ha più di 360 secondi,
|
||||
connesso o se l'ultimo header ha più di 1800 secondi (con blocchi da 120 s
|
||||
una soglia di 360 s scatterebbe a vuoto ogni ventina di blocchi,
|
||||
e^-3 ≈ 5%; la rotazione di [ELC-21] resta a 360 s perché agisce solo se un
|
||||
altro server ha davvero una punta più alta),
|
||||
[api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
|
||||
corrente resta visibile anche nella barra di stato della catena di `/` (da
|
||||
`chain_tip_height` di `GET /rounds/current`).
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
|
||||
*Stato:* da implementare (B-86, B-92) · *Oggi:* `/health` risponde sempre
|
||||
*Stato:* da implementare (B-92) · *Oggi:* `/health` risponde sempre
|
||||
`ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
|
||||
mostra solo l'altezza.
|
||||
@@ -325,12 +325,30 @@ class _PinWriter(FakeWriter):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _answer_features(reader, writer, result=None):
|
||||
"""Make `writer` answer `server.features` the moment it is asked: a reply fed up front
|
||||
would be read before its request is registered, and dropped."""
|
||||
from app.wallet.plm_network import GENESIS_HASH
|
||||
|
||||
original = writer.write
|
||||
|
||||
def write(data):
|
||||
original(data)
|
||||
sent = json.loads(data.decode())
|
||||
if sent["method"] == "server.features":
|
||||
reply = {"genesis_hash": GENESIS_HASH} if result is None else result
|
||||
reader.feed_data((json.dumps({"id": sent["id"], "result": reply}) + "\n").encode())
|
||||
|
||||
writer.write = write
|
||||
|
||||
|
||||
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')
|
||||
_answer_features(reader, writer)
|
||||
|
||||
async def fake_open_connection(host, port, **kwargs):
|
||||
return reader, writer
|
||||
@@ -413,3 +431,22 @@ async def test_only_a_server_error_object_is_an_explicit_refusal():
|
||||
silent._read_task.cancel()
|
||||
finally:
|
||||
client_module._REQUEST_TIMEOUT_SECONDS = original
|
||||
|
||||
|
||||
async def test_a_server_on_another_chain_is_refused_at_the_handshake(monkeypatch):
|
||||
"""ELC-30: Bitcoin's genesis (or no genesis at all) fails like an unreachable server."""
|
||||
import pytest
|
||||
|
||||
bitcoin = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26e"
|
||||
for result in ({"genesis_hash": bitcoin}, {}):
|
||||
writer = FakeWriter()
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(b'{"id": 1, "result": ["fake", "1.4"]}\n')
|
||||
_answer_features(reader, writer, result)
|
||||
|
||||
async def fake_open_connection(host, port, reader=reader, writer=writer, **kwargs):
|
||||
return reader, writer
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_connection", fake_open_connection)
|
||||
with pytest.raises(ElectrumError, match="not on the PLM chain"):
|
||||
await ElectrumClient("localhost", 1).connect()
|
||||
Reference in new issue
Block a user