From 13cb66cc0604b2272dd746f1ac0bafa8d65a6afd Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 14 Oct 2026 14:04:35 +0200 Subject: [PATCH] Require a real Electrum quorum and allow certificate pinning (B-84, ELC-01, ELC-03, ELC-14, ELC-19, ARC-20, OPS-12) An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every corroboration pass, and with fallbacks the majority was taken over whoever answered, so one reachable fallback decided alone. TLS certificates were never checked. - _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES) and a strict majority of the answers; no other server configured, or fewer than 2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule. - config.validate_quorum_servers, called from the lifespan after parse_endpoints, refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true (which logs a warning at every start). - Optional pinning: `host:port[:mode]:pin=` per fallback and ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the DER certificate after the TLS handshake and a mismatch fails like an unreachable server. A pin on a plaintext entry is a startup error. - .env.example now ships the four bootstrap servers and documents both new variables; docs/setup.md, spec, storico and BUGS.md updated. - Tests changed because the spec changed: the two "no other servers configured -> True" corroboration tests now assert that outcome only under allow_no_quorum. Note: a dev .env with fewer than 2 fallback servers will now refuse to start. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 25 +++- BUGS.md | 10 -- CLAUDE.md | 8 +- README.md | 2 +- app/config.py | 49 +++++++ app/electrum/client.py | 95 ++++++++++--- app/electrum/listener.py | 35 +++-- app/main.py | 10 +- ...i-decisione-sposta-denaro-base-risposta.md | 7 +- docs/architettura.md | 21 ++- docs/connessione-electrum.md | 23 +--- docs/limiti-noti.md | 29 ++-- docs/operativita.md | 13 +- docs/setup.md | 12 +- docs/sicurezza.md | 10 +- docs/storico-bug.md | 6 +- tests/unit/test_electrum_client.py | 127 ++++++++++++++++++ tests/unit/test_electrum_listener.py | 94 ++++++++++++- 18 files changed, 451 insertions(+), 125 deletions(-) diff --git a/.env.example b/.env.example index f65bfc3..8d29378 100644 --- a/.env.example +++ b/.env.example @@ -3,17 +3,32 @@ ELECTRUM_PORT=50002 ELECTRUM_USE_SSL=true # Additional Electrum servers to fall back to, comma-separated. Each entry is -# `host:port` (TLS, the normal case) or `host:port:notls`. The listener rotates -# over the primary above plus these, so one unreachable server costs a single -# reconnect attempt instead of an outage — every deposit credit, broadcast and +# `host:port` (TLS, the normal case), `host:port:notls`, or `host:port:pin=` +# (TLS, and the server's DER certificate must hash to that SHA-256, in hex). The +# listener rotates over the primary above plus these, so one unreachable server costs +# a single reconnect attempt instead of an outage — every deposit credit, broadcast and # confirmation goes through this one connection, which makes a single server the # platform's biggest single point of failure. A typo here fails at startup rather # than during the outage when the fallback is what you need. +# +# AT LEAST 2 are required (B-84): every decision that moves money is corroborated by +# the *other* servers and needs two independent answers, so with fewer than two +# fallbacks the server refuses to start (override: ALLOW_NO_QUORUM, development only). +# Configure three or more in production, so one being down does not stop the lottery. +# # These are the mainnet bootstrap servers from the PalladiumWallet repo # (src/Core/Chain/ChainProfiles.cs, ChainProfiles.Mainnet.BootstrapServers) — the # same ones the reference wallet falls back to: -# Example: ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002 -ELECTRUM_FALLBACK_SERVERS= +ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002 + +# Optional: SHA-256 (hex) of the primary server's DER certificate. A connection that +# presents another certificate fails like an unreachable server. Empty = not pinned. +ELECTRUM_CERT_PIN= + +# Development only: start with fewer than 2 fallback servers (corroboration then falls +# back to the weak rule: a single answer decides, or nothing is checked at all). +# NEVER true in production. +ALLOW_NO_QUORUM=false # Fernet key protecting the master xprv at rest. Generate with: # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" diff --git a/BUGS.md b/BUGS.md index 10c9e14..2bf7f96 100644 --- a/BUGS.md +++ b/BUGS.md @@ -237,16 +237,6 @@ Fix: a confirmation is accepted once the quorum returns a valid merkle proof (`blockchain.transaction.get_merkle`) against a header we hold; reinstatement needs the same quorum as a credit (B-59); broadcasts fan out (B-76). -### B-84 — The quorum can be one server, or none, silently - -An empty `ELECTRUM_FALLBACK_SERVERS` (the `.env.example` default) disables every -corroboration; with fallbacks, the majority is over whoever answers, so one reachable -fallback decides. TLS certificates are not verified. - -Fix: at least 2 fallbacks and at least 2 responders for every corroboration, or the -server refuses to start (override `ALLOW_NO_QUORUM=true`, development only); optional -certificate pinning per endpoint (`host:port:pin=`). - ### B-85 — Reorgs are neither followed nor surfaced Nothing detects a reorg; a 1-block reorg is treated as a hostile server (B-74). diff --git a/CLAUDE.md b/CLAUDE.md index 7e075d7..8547b1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 414 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. +All 10 stages of the original build order are code-complete and unit-tested — 425 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. @@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip -python -m pytest # all 414 tests +python -m pytest # all 425 tests python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test ``` @@ -154,11 +154,11 @@ Chain access goes through `listener.client`, passed as `lambda: listener.client` One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences: -- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn. +- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn. - **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently. - **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive. - **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. Two more headers are refused *without* ending the session, since neither implies a hostile server (B-64): one at a height we already hold a header for (a reorg at the tip, or one server disagreeing — the hash committed to for a height is never swapped under us, and `corroborate_header` is what catches us holding an orphan), and one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63. -- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — with one exception today: `deposits/service.py:reinstate_reappeared_utxos` restores a reappeared UTXO on the active server's word alone. No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing. A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep. +- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — with one exception today: `deposits/service.py:reinstate_reappeared_utxos` restores a reappeared UTXO on the active server's word alone. It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep. On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep. diff --git a/README.md b/README.md index 8b67e14..8816070 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -414 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +425 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/config.py b/app/config.py index 1314ea1..d454a87 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,5 @@ +import logging + from pydantic_settings import BaseSettings, SettingsConfigDict # Minimum JWT signing key length. HS256 keys shorter than the hash output weaken @@ -5,6 +7,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # than being shipped by accident. MIN_JWT_SECRET_LENGTH = 32 +# B-84 (ELC-19, ELC-14): fallback servers needed besides the primary. With fewer than +# three servers, a corroboration (which never asks the active one) cannot have two +# independent answers — one server would decide, or none would. +MIN_QUORUM_FALLBACK_SERVERS = 2 +MIN_QUORUM_RESPONSES = 2 + +logger = logging.getLogger(__name__) + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") @@ -21,6 +31,15 @@ class Settings(BaseSettings): # connection, which makes a single hardcoded server the platform's biggest # single point of failure. Parsed by electrum.client.parse_endpoints. electrum_fallback_servers: str = "" + # B-84 (ELC-03): SHA-256 (hex) of the primary server's DER certificate. Empty = not + # pinned. Fallback servers carry their own `:pin=` in the list above. + electrum_cert_pin: str = "" + # B-84 (ELC-19): development only. Lets the server start with fewer than + # MIN_QUORUM_FALLBACK_SERVERS fallback servers, and corroboration then follows the + # reduced rule (no other server: passes; otherwise a strict majority of however + # many answer, even one). Never for production: it is exactly the single-server + # trust the quorum exists to remove. + allow_no_quorum: bool = False xprv_encryption_key: str = "" master_key_path: str = "./master.xprv.enc" @@ -79,3 +98,33 @@ def validate_runtime_secrets(config: Settings | None = None) -> None: ) if problems: raise ConfigError("invalid configuration in .env: " + "; ".join(problems)) + + +def validate_quorum_servers(endpoints: list, config: Settings | None = None) -> None: + """B-84 (ELC-19): refuse to serve with fewer than MIN_QUORUM_FALLBACK_SERVERS + fallback servers (`endpoints` is the deduplicated rotation: primary first, then the + fallbacks), unless ALLOW_NO_QUORUM=true. The old behaviour — an empty + ELECTRUM_FALLBACK_SERVERS, the .env.example default — silently made every + corroboration pass, i.e. one server decided the draw, the credits and the spends. + + Called from the lifespan, after parse_endpoints, rather than from + validate_runtime_secrets: it needs the parsed list, and a Settings validator would + couple every import (tests included) to a populated .env. + """ + config = config or settings + fallbacks = len(endpoints) - 1 + if fallbacks >= MIN_QUORUM_FALLBACK_SERVERS: + return + if config.allow_no_quorum: + logger.warning( + "ALLOW_NO_QUORUM=true: only %s fallback Electrum server(s) configured (%s needed) — " + "corroboration is reduced to the weaker rule. Development only, never in production.", + fallbacks, + MIN_QUORUM_FALLBACK_SERVERS, + ) + return + raise ConfigError( + f"ELECTRUM_FALLBACK_SERVERS lists {fallbacks} usable fallback server(s) besides the primary, " + f"but at least {MIN_QUORUM_FALLBACK_SERVERS} are required so every corroboration has two independent " + "answers (set ALLOW_NO_QUORUM=true to override, development only)" + ) diff --git a/app/electrum/client.py b/app/electrum/client.py index bf98665..7cce201 100644 --- a/app/electrum/client.py +++ b/app/electrum/client.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import itertools import json import logging @@ -13,50 +14,91 @@ logger = logging.getLogger(__name__) class ElectrumEndpoint(NamedTuple): """One server to connect to. The listener rotates over a list of these so a single unreachable or misbehaving server doesn't take the platform down — - every consumer of PLM chain data goes through this one connection.""" + every consumer of PLM chain data goes through this one connection. + + `pin` (B-84, ELC-03) is the SHA-256 of the server's DER certificate, in lower-case + hex; when set, a connection presenting any other certificate fails like an + unreachable server. Only meaningful with TLS.""" host: str port: int use_ssl: bool = True + pin: str | None = None def __str__(self) -> str: - return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}" + return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}{' (pinned)' if self.pin else ''}" + + +_PIN_PATTERN = re.compile(r"[0-9a-f]{64}") + + +def normalize_pin(raw: str, entry: str) -> str: + """A certificate pin as lower-case hex, or ValueError: 64 hexadecimal digits.""" + pin = raw.strip().lower() + if not _PIN_PATTERN.fullmatch(pin): + raise ValueError(f"invalid certificate pin in Electrum server {entry!r}: expected 64 hex digits (sha256)") + return pin def parse_endpoints( - primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str + primary_host: str, + primary_port: int, + primary_use_ssl: bool, + fallback_spec: str, + primary_pin: str = "", ) -> list[ElectrumEndpoint]: """Build the connection rotation: the primary server first, then whatever ELECTRUM_FALLBACK_SERVERS lists. `fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal - case) or `host:port:notls`. Malformed entries raise ValueError rather than - being skipped: a typo in a fallback server is something to fix at startup, - not to discover during an outage, when the fallback is what's needed. + case), `host:port:notls`, `host:port:pin=` (TLS with a pinned certificate, + B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=`. A pin on a + plaintext entry is refused: there is no certificate to pin. `primary_pin` is the + same pin for the primary (ELECTRUM_CERT_PIN). Malformed entries raise ValueError + rather than being skipped: a typo in a fallback server is something to fix at + startup, not to discover during an outage, when the fallback is what's needed. Duplicates are dropped, keeping first position. """ - endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)] + syntax = "expected host:port[:notls][:pin=]" + pin = normalize_pin(primary_pin, "ELECTRUM_CERT_PIN") if primary_pin.strip() else None + if pin and not primary_use_ssl: + raise ValueError("ELECTRUM_CERT_PIN needs TLS: ELECTRUM_USE_SSL is off") + endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl, pin)] for raw in fallback_spec.split(","): entry = raw.strip() if not entry: continue parts = entry.split(":") - if len(parts) not in (2, 3): - raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]") + if not 2 <= len(parts) <= 4: + raise ValueError(f"invalid Electrum server {entry!r}: {syntax}") host, port = parts[0].strip(), parts[1].strip() if not host or not port.isdigit(): - raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]") + raise ValueError(f"invalid Electrum server {entry!r}: {syntax}") use_ssl = True - if len(parts) == 3: - flag = parts[2].strip().lower() - if flag not in ("ssl", "tls", "notls", "plain"): + pin = None + mode_seen = False + for extra in parts[2:]: + extra = extra.strip() + if extra.lower().startswith("pin="): + if pin is not None: + raise ValueError(f"invalid Electrum server {entry!r}: more than one pin") + pin = normalize_pin(extra[4:], entry) + continue + flag = extra.lower() + if mode_seen or flag not in ("ssl", "tls", "notls", "plain"): raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}") + mode_seen = True use_ssl = flag in ("ssl", "tls") - endpoints.append(ElectrumEndpoint(host, int(port), use_ssl)) + if pin and not use_ssl: + raise ValueError(f"invalid Electrum server {entry!r}: a pin needs TLS") + endpoints.append(ElectrumEndpoint(host, int(port), use_ssl, pin)) deduped: list[ElectrumEndpoint] = [] + seen: set[tuple] = set() for endpoint in endpoints: - if endpoint not in deduped: + key = (endpoint.host, endpoint.port, endpoint.use_ssl) + if key not in seen: + seen.add(key) deduped.append(endpoint) return deduped @@ -100,10 +142,11 @@ class ElectrumClient: notification queues nobody will ever fill again. """ - def __init__(self, host: str, port: int, use_ssl: bool = True): + def __init__(self, host: str, port: int, use_ssl: bool = True, pin: str | None = None): self.host = host self.port = port self.use_ssl = use_ssl + self.pin = pin.lower() if pin else None self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None self._id_counter = itertools.count(1) @@ -115,7 +158,10 @@ class ElectrumClient: async def connect(self) -> None: # Electrum servers commonly present self-signed certs; the protocol's trust # model is server consensus, not TLS PKI, so we only use SSL for transport - # encryption and don't verify the certificate chain/hostname. + # encryption and don't verify the certificate chain/hostname. What can be + # asked instead is that the certificate be *the* one the operator pinned + # (B-84, ELC-03): without a pin, whoever sits on the path to a server takes + # its place, and only the quorum stands between that and the draw. ssl_context = None if self.use_ssl: ssl_context = ssl.create_default_context() @@ -124,9 +170,24 @@ class ElectrumClient: self._reader, self._writer = await asyncio.open_connection( self.host, self.port, ssl=ssl_context, limit=_MAX_LINE_BYTES ) + if self.pin: + try: + self._check_pin() + except ElectrumError: + self._writer.close() + raise self._read_task = asyncio.create_task(self._read_loop()) await self.request("server.version", ["plm-lottery", "1.4"]) + def _check_pin(self) -> None: + """The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03).""" + ssl_object = self._writer.get_extra_info("ssl_object") if self._writer is not None else None + der = ssl_object.getpeercert(binary_form=True) if ssl_object is not None else None + if not der: + raise ElectrumError(f"{self.host}:{self.port} presented no certificate to check against the pin") + if hashlib.sha256(der).hexdigest() != self.pin: + raise ElectrumError(f"{self.host}:{self.port} certificate does not match the configured pin") + async def close(self) -> None: self._closed.set() if self._read_task is not None: diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 1c9246e..875e6f2 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -13,6 +13,7 @@ from app.deposits.service import ( mark_utxos_spent_externally, reinstate_reappeared_utxos, ) +from app.config import MIN_QUORUM_RESPONSES from app.electrum.client import ElectrumClient, ElectrumEndpoint from app.electrum.scripthash import address_to_scripthash from app.rounds.draw import ( @@ -63,10 +64,13 @@ class ElectrumListener: client_factory: Callable[[ElectrumEndpoint], ElectrumClient], session_factory: async_sessionmaker, endpoints: list[ElectrumEndpoint] | None = None, + allow_no_quorum: bool = False, ): self._client_factory = client_factory self._session_factory = session_factory self._endpoints = list(endpoints or []) + # B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority. + self._allow_no_quorum = allow_no_quorum self._endpoint_index = 0 self._scripthash_to_user: dict[str, int] = {} # Retains address_for_new_user's fire-and-forget subscribe task so it @@ -363,21 +367,36 @@ class ElectrumListener: would control) and require a strict majority of the ones that actually answer to agree, via `agrees`, with what our own connection reported. - Returns True with no other servers configured — nothing to corroborate - against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty - (see CLAUDE.md). Returns False (never silently "passes") if none of the - others could be reached, since an unreachable network proves nothing - either way. + The quorum is strict (B-84, ELC-14): it passes only when **at least + MIN_QUORUM_RESPONSES** of the other servers answered *and* a strict majority of + those that answered agree. Fewer answers — one reachable server, or none, or no + other server configured at all — fail, since a lone voice decides nothing + independently and an unreachable network proves nothing. Startup already refuses + to run with fewer than two fallbacks (config.validate_quorum_servers), so with a + sound configuration "too few answered" means servers are down, and the + corroboration simply fails (a delay, never a loss — the callers retry). + + Only with ALLOW_NO_QUORUM=true (development) does the reduced rule apply: no + other server configured passes, and otherwise a strict majority of whoever + answered decides, even a single answer. """ others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint] - if not others: + if not others and self._allow_no_quorum: return True + if not others: + logger.warning("could not corroborate %s: no other Electrum server is configured", description) + return False results = await asyncio.gather(*(ask(endpoint) for endpoint in others)) responded = [result for result in results if result is not None] - if not responded: + minimum = 1 if self._allow_no_quorum else MIN_QUORUM_RESPONSES + if len(responded) < minimum: logger.warning( - "could not corroborate %s with any of %s other configured server(s)", description, len(others) + "could not corroborate %s: %s of %s other configured server(s) answered, %s needed", + description, + len(responded), + len(others), + minimum, ) return False diff --git a/app/main.py b/app/main.py index 6166bc7..021297d 100644 --- a/app/main.py +++ b/app/main.py @@ -22,7 +22,7 @@ from app.api.routes.users import router as users_router from app.api.routes.withdrawals import router as withdrawals_router from app.auth.routes import router as auth_router from app.api.errors import ApiError -from app.config import settings, validate_runtime_secrets +from app.config import settings, validate_quorum_servers, validate_runtime_secrets from app.db.base import AsyncSessionLocal from app.deposits.reconcile import DepositReconciler from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient: - return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl) + return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin) @asynccontextmanager @@ -50,10 +50,14 @@ async def lifespan(app: FastAPI): settings.electrum_port, settings.electrum_use_ssl, settings.electrum_fallback_servers, + settings.electrum_cert_pin, ) + validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints)) - listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints) + listener = ElectrumListener( + _make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum + ) app.state.electrum_listener = listener app.state.user_locks = UserLocks() diff --git a/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md b/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md index a373043..4f4d6b8 100644 --- a/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md +++ b/docs/adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md @@ -1,6 +1,6 @@ # ADR-07 — Ogni decisione che sposta denaro in base a una risposta di rete è corroborata da un quorum degli altri server configurati -- **Stato:** Accettata — implementazione da completare (B-83, B-84) +- **Stato:** Accettata — implementazione da completare (B-83) - **Area:** Conferme e fiducia nella rete - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -33,6 +33,5 @@ di 2 server, la corroborazione fallisce. *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.corroborate_header` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · `app/main.py:lifespan` · *Test:* `tests/unit/test_electrum_listener.py` -*Stato:* da implementare (B-83, B-84) · *Oggi:* senza server di riserva la -corroborazione passa sempre, basta una risposta, e conferme, ripristini e -abbandoni si decidono sul solo server attivo. +*Stato:* da implementare (B-83) · *Oggi:* conferme, ripristini e abbandoni +si decidono sul solo server attivo. diff --git a/docs/architettura.md b/docs/architettura.md index f3e2084..211687f 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -342,7 +342,7 @@ sequenceDiagram U->>L: startup L->>L: validate_runtime_secrets() L->>L: parse_endpoints(...) - L->>L: quorum minimo (B-84, da implementare) + L->>L: quorum minimo (validate_quorum_servers) L->>L: verifica della master key (B-89, da implementare) L->>L: START_PAUSED (B-90, da implementare) L->>L: app.state.electrum_listener, app.state.user_locks @@ -424,8 +424,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). scritto nel log. *Codice:* `app/electrum/client.py:parse_endpoints` · *Test:* `tests/unit/test_electrum_client.py` - *Stato:* da implementare (B-84) · *Oggi:* la sintassi è `host:porta[:notls]`, - senza pin del certificato. - **[ARC-60]** **Controlli di avvio.** Dopo [ARC-19] e [ARC-20] e prima di creare i task, il lifespan esegue nell'ordine, e ogni controllo fallito @@ -448,11 +446,11 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]). dei task: gira alla prima connessione del listener, e finché non si è concluso senza trovare cronologia la registrazione è rifiutata ([operativita.md](operativita.md#ripristino), B-90). - *Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · - *Test:* — - *Stato:* da implementare (B-84, B-89, B-90) · *Oggi:* dopo i segreti e gli - endpoint il lifespan crea subito i task; la chiave si decifra alla prima - registrazione o al primo pagamento. + *Codice:* `app/main.py:lifespan` · `app/config.py:validate_runtime_secrets` · `app/config.py:validate_quorum_servers` · + *Test:* `tests/unit/test_electrum_listener.py` + *Stato:* da implementare (B-89, B-90), per i controlli 2 e 3 · *Oggi:* dopo + i segreti, gli endpoint e il quorum il lifespan crea subito i task; la + chiave si decifra alla prima registrazione o al primo pagamento. - **[ARC-21]** Lo stato condiviso con le route vive su `app.state`: `electrum_listener` (l'`ElectrumListener`) e `user_locks` (un `UserLocks`), creati nel lifespan; `auth_rate_limiters` viene invece creato @@ -610,10 +608,9 @@ La configurazione è divisa in due posti con regole diverse. | `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) | *Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py` - *Stato:* da implementare (B-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`, - `START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è - la variabile `XPRV_ENCRYPTION_KEY` e la sintassi delle riserve non ha - `pin=`. + *Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e + `XPRV_ENCRYPTION_KEY_FILE` non esistono, la chiave Fernet è la variabile + `XPRV_ENCRYPTION_KEY`. - **[ARC-31]** **Parametri operativi e di business** (costo della scommessa, durata e pausa del round, animazione, commissione di rete, timeout RBF, indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md index 5b34498..bade6c5 100644 --- a/docs/connessione-electrum.md +++ b/docs/connessione-electrum.md @@ -27,15 +27,14 @@ Le difese sono cinque. `ELECTRUM_USE_SSL`) seguito da quelli di riserva. Ogni voce di riserva è `host:porta` (TLS), `host:porta:` con `` tra `ssl`/`tls` (TLS) e `notls`/`plain` (in chiaro), oppure `host:porta:pin=` (TLS con - certificato fissato, [ELC-03]); `` sono 64 cifre esadecimali. Le + certificato fissato, [ELC-03]); `` sono 64 cifre esadecimali (maiuscole accettate, conservate in + minuscolo); `pin=` può seguire anche `` con TLS, mai `notls`/`plain` + (non c'è un certificato da fissare). Le voci vuote sono ignorate e i duplicati eliminati mantenendo la prima posizione. Una voce malformata blocca l'avvio subito, non durante il guasto in cui servirebbe. *Codice:* `app/electrum/client.py:parse_endpoints` · *Test:* `tests/unit/test_electrum_client.py` - *Stato:* da implementare (B-84), per la forma `pin=` · *Oggi:* il terzo - campo accetta solo `ssl`/`tls`/`notls`/`plain`; `pin=…` è una voce - malformata. - **[ELC-02]** Dopo ogni sessione fallita o caduta si prova **subito** il server successivo nell'elenco, a rotazione; l'attesa crescente (da 1s raddoppiando fino a 30s) scatta solo dopo che ogni server ha fallito una @@ -52,9 +51,8 @@ Le difese sono cinque. irraggiungibile e si passa al successivo ([ELC-02]). Senza pin la fiducia non viene dal TLS ma dal quorum ([§ 5](#5-il-quorum)). Ogni connessione si apre con la stretta di mano `server.version`. - *Codice:* `app/electrum/client.py:ElectrumClient.connect` · *Test:* — - *Stato:* da implementare (B-84) · *Oggi:* nessun certificato viene mai - verificato; non esiste un modo di fissarlo. + *Codice:* `app/electrum/client.py:ElectrumClient.connect`, `app/electrum/client.py:ElectrumClient._check_pin`, `app/main.py:_make_electrum_client` · + *Test:* `tests/unit/test_electrum_client.py` - **[ELC-19]** Il server **rifiuta di avviarsi** se, dopo l'eliminazione dei duplicati, l'elenco contiene meno di **2 server di riserva** oltre al principale: con meno di tre server il quorum di [ELC-14] non può mai avere @@ -62,10 +60,8 @@ Le difese sono cinque. `ALLOW_NO_QUORUM=true`, **solo per lo sviluppo**: il server parte, scrive un avviso nei log a ogni avvio, e le corroborazioni seguono la regola ridotta di [ELC-14]. - *Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Test:* — - *Stato:* da implementare (B-84) · *Oggi:* un `ELECTRUM_FALLBACK_SERVERS` - vuoto (il valore di `.env.example`) è accettato in silenzio e disattiva - ogni corroborazione. + *Codice:* `app/config.py:validate_quorum_servers`, `app/main.py:lifespan` · + *Test:* `tests/unit/test_electrum_listener.py` ## 2. Richieste con limite di tempo @@ -299,11 +295,6 @@ server attivo; sono le righe [ELC-26], [ELC-28], [ELC-25] e [ELC-27]. maggioranza stretta delle risposte, anche di una sola. *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · *Test:* `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-84) · *Oggi:* vale per tutti la regola ridotta. - Senza server di riserva ogni verifica passa; con server di riserva la - maggioranza è calcolata su chi ha risposto, quindi **una risposta su una - decide**: se è raggiungibile un solo altro server, la sua parola basta. - Nessuna risposta fa fallire la verifica. | ID | Prima di… | Si verifica che… | |---|---|---| diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index abe89b2..aff8895 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -258,23 +258,20 @@ implementato il [piano delle correzioni](piano-correzioni.md). *Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header` -- **[LIM-11]** **Senza server di riserva configurati la corroborazione passa - sempre.** Con `ELECTRUM_FALLBACK_SERVERS` vuoto (il default di - `.env.example`) il solo server primario decide estrazione, spese esterne e - accrediti. Anche con server di riserva, la maggioranza è calcolata **su chi - risponde**: un solo server di riserva raggiungibile decide da solo. I - certificati TLS non sono verificati. - - *Impatto:* chi controlla il server primario (o la maggioranza dei server +- **[LIM-11]** **I server del quorum li sceglie l'operatore.** Il server + rifiuta di partire con meno di 2 server di riserva e ogni corroborazione + richiede almeno 2 risposte ([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md), B-84), ma più server controllati dalla + stessa parte contano come voci indipendenti. I certificati TLS non sono + verificati contro un'autorità; si possono fissare per endpoint + (`host:porta:pin=`, `ELECTRUM_CERT_PIN`). + - *Impatto:* chi controlla la maggioranza dei server configurati (o raggiungibili) può scegliere il vincitore o alterare i saldi mostrati. - - *Mitigazione attuale:* nessun server risponde → la corroborazione - **fallisce** (una rete irraggiungibile non prova nulla). - - *Stato:* da chiudere con B-84. - - *Per chiuderlo:* almeno 2 server di riserva configurati (altrimenti il - server rifiuta di partire, salvo `ALLOW_NO_QUORUM=true` in sviluppo) e - almeno 2 risposte per ogni corroborazione ([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)); pin facoltativo del - certificato per endpoint (`host:porta:pin=`). Resta accettato - dopo il piano che i server del quorum siano scelti dall'operatore: più - server controllati dalla stessa parte contano come voci indipendenti. + - *Mitigazione attuale:* quorum minimo obbligatorio; meno di 2 risposte → + la corroborazione **fallisce** (una rete irraggiungibile non prova + nulla); pin facoltativo del certificato. Il vecchio caso «senza riserve + tutto passa» e «una sola risposta decide» è chiuso da B-84. + - *Per chiuderlo:* non si chiude: resta una scelta dell'operatore usare + server indipendenti fra loro. *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` diff --git a/docs/operativita.md b/docs/operativita.md index 8e1b34c..b2ddcb0 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -69,10 +69,9 @@ dovranno allora rispettare quel proprietario. `app/config.py:validate_runtime_secrets`, `app/electrum/client.py:parse_endpoints`, `app/wallet/hd.py:_load_account_key` · *Test:* `tests/unit/test_config.py`, `tests/unit/test_electrum_client.py` - *Stato:* da implementare (B-89, B-84) · *Oggi:* della master key si - controlla solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; - una chiave sbagliata emerge alla prima derivazione, e il server parte anche - senza server di riserva. + *Stato:* da implementare (B-89) · *Oggi:* della master key si controlla + solo che il file esista e che `XPRV_ENCRYPTION_KEY` non sia vuota; una + chiave sbagliata emerge alla prima derivazione. - **[OPS-03]** Il database SQLite lavora in modalità **WAL**: accanto a `plm_lottery.db` esistono `plm_lottery.db-wal` e `plm_lottery.db-shm`, e le @@ -469,12 +468,6 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). *Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`, `app/main.py:lifespan` · *Test:* `tests/unit/test_electrum_listener.py` - *Stato:* da implementare (B-84) · *Oggi:* con `ELECTRUM_FALLBACK_SERVERS` - vuoto (il valore di `.env.example`) il server parte e la corroborazione - passa sempre: un solo server decide tutto. Con server di riserva la - maggioranza è su chi risponde, quindi basta una sola riserva raggiungibile; - se nessuna risponde, la corroborazione fallisce - (`draw_header_corroboration_failed` a ogni blocco). ### Round bloccato in `drawing` diff --git a/docs/setup.md b/docs/setup.md index cb2a6b2..936d41e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -47,14 +47,18 @@ endpoint admin inclusi, quindi restano disattivati a meno di non impostarlo esplicitamente a `true` — utile in locale, da evitare in produzione. `ELECTRUM_FALLBACK_SERVERS` elenca i server di riserva, separati da virgola, nel -formato `host:porta` (TLS, il caso normale) oppure `host:porta:notls`. Esempio: +formato `host:porta` (TLS, il caso normale), `host:porta:notls` oppure +`host:porta:pin=` (TLS con il certificato del server fissato). Esempio: ``` -ELECTRUM_FALLBACK_SERVERS=nodo2.example.net:50002,nodo3.example.net:50001:notls +ELECTRUM_FALLBACK_SERVERS=nodo2.example.net:50002,nodo3.example.net:50002,nodo4.example.net:50002:pin= ``` -Vengono provati a rotazione dopo il primario. Attenzione: un valore scritto male -**blocca l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il +Vengono provati a rotazione dopo il primario e servono anche da quorum: ne +servono **almeno 2**, altrimenti l'app non parte (`ALLOW_NO_QUORUM=true` lo +permette, ma solo in sviluppo). `ELECTRUM_CERT_PIN` fissa allo stesso modo il +certificato del primario. Attenzione: un valore scritto male **blocca +l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il disservizio in cui il fallback serve davvero. `JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti diff --git a/docs/sicurezza.md b/docs/sicurezza.md index ee0dbd5..ce29d03 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -135,8 +135,8 @@ l'host. riavvio. L'elenco completo è in [architettura.md](architettura.md#configurazione). *Codice:* `app/config.py:Settings` - *Stato:* da implementare (B-84, B-90, B-91) · *Oggi:* `ALLOW_NO_QUORUM`, - `START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono. + *Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e + `XPRV_ENCRYPTION_KEY_FILE` non esistono. `.env.example` documenta le chiavi necessarie e il comando per generarle; non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque @@ -629,12 +629,6 @@ loro significato per la sicurezza. più server controllati dalla stessa parte contano come voci indipendenti ([LIM-11](limiti-noti.md)). -*Oggi* (B-84): un `ELECTRUM_FALLBACK_SERVERS` vuoto (il valore di -`.env.example`) è accettato in silenzio e fa passare ogni corroborazione; con -server di riserva la maggioranza è calcolata su chi risponde, quindi una sola -risposta decide; nessun certificato è verificato e non esiste un modo di -fissarlo. - ## Host e catena di fornitura Chi controlla l'host controlla tutti i fondi ([Modello di diff --git a/docs/storico-bug.md b/docs/storico-bug.md index 5446698..b4407cb 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -232,7 +232,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | **aperto** | — | RND | | B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND | | B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | **aperto** | — | ELC, TX, DEP | -| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | **aperto** | — | ELC, SEC | +| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC | | B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | **aperto** | — | ELC, RND | | B-86 | Un server connesso ma fermo non viene mai sostituito | Alta | **aperto** | — | ELC, OPS | | B-87 | L'RBF può lasciare un resto sotto la soglia di dust | Alta | **aperto** | — | TX | @@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). - **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84. - **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (8 critici, - 8 alti, 6 medi), da implementare nell'ordine di + 7 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/tests/unit/test_electrum_client.py b/tests/unit/test_electrum_client.py index f355158..28a2ce8 100644 --- a/tests/unit/test_electrum_client.py +++ b/tests/unit/test_electrum_client.py @@ -253,3 +253,130 @@ async def test_a_reply_that_is_not_json_fails_its_request_not_the_connection(): assert await asyncio.wait_for(task2, timeout=1) == 7 client._read_task.cancel() + + +# --- B-84 (ELC-01, ELC-03): optional certificate pinning -------------------------- + +_PIN = "ab" * 32 + + +def test_parse_endpoints_reads_pins_on_fallbacks_and_the_primary(): + """ELC-01 / ELC-03 / B-84: `host:port:pin=` and `host:port::pin=`, + plus ELECTRUM_CERT_PIN for the primary; the pin is normalised to lower case.""" + from app.electrum.client import ElectrumEndpoint, parse_endpoints + + endpoints = parse_endpoints( + "primary.example", + 50002, + True, + f"a.example:50002:pin={_PIN.upper()}, b.example:50002:tls:pin={_PIN}, c.example:50001:notls", + primary_pin=_PIN, + ) + assert endpoints == [ + ElectrumEndpoint("primary.example", 50002, True, _PIN), + ElectrumEndpoint("a.example", 50002, True, _PIN), + ElectrumEndpoint("b.example", 50002, True, _PIN), + ElectrumEndpoint("c.example", 50001, False, None), + ] + + +def test_parse_endpoints_rejects_malformed_pins(): + """ELC-01 / B-84: a bad pin blocks the start, like any malformed entry.""" + import pytest + + from app.electrum.client import parse_endpoints + + for bad in [ + "h:1:pin=", + "h:1:pin=abc", + f"h:1:pin={'zz' * 32}", + f"h:1:notls:pin={_PIN}", # nothing to pin without TLS + f"h:1:pin={_PIN}:pin={_PIN}", + f"h:1:tls:ssl:pin={_PIN}", + f"h:1:tls:ssl:notls:pin={_PIN}", # too many fields + ]: + with pytest.raises(ValueError): + parse_endpoints("primary.example", 50002, True, bad) + with pytest.raises(ValueError): + parse_endpoints("primary.example", 50002, True, "", primary_pin="nothex") + with pytest.raises(ValueError): + parse_endpoints("primary.example", 50002, False, "", primary_pin=_PIN) + + +class _FakeSslObject: + def __init__(self, der): + self._der = der + + def getpeercert(self, binary_form=False): + assert binary_form + return self._der + + +class _PinWriter(FakeWriter): + def __init__(self, der): + super().__init__() + self._ssl = _FakeSslObject(der) if der is not None else None + self.closed = False + + def get_extra_info(self, name): + return self._ssl if name == "ssl_object" else None + + def close(self): + self.closed = True + + +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') + + async def fake_open_connection(host, port, **kwargs): + return reader, writer + + monkeypatch.setattr(asyncio, "open_connection", fake_open_connection) + client = ElectrumClient("localhost", 1, True, pin) + try: + await client.connect() + finally: + if client._read_task is not None: + client._read_task.cancel() + return writer + + +async def test_a_connection_with_the_pinned_certificate_is_accepted(monkeypatch): + """ELC-03 / B-84: the DER certificate hashes to the pin.""" + import hashlib + + der = b"the server certificate" + writer = await _connect_with_pin(monkeypatch, hashlib.sha256(der).hexdigest().upper(), der) + assert not writer.closed + + +async def test_a_connection_with_another_certificate_fails_like_an_unreachable_server(monkeypatch): + """ELC-03 / B-84: mismatch (and no certificate at all) raise ElectrumError and close.""" + import hashlib + + import pytest + + pin = hashlib.sha256(b"the pinned certificate").hexdigest() + for der in (b"someone else in the middle", None): + writer = _PinWriter(der) + + async def fake_open_connection(host, port, writer=writer, **kwargs): + return asyncio.StreamReader(), writer + + monkeypatch.setattr(asyncio, "open_connection", fake_open_connection) + with pytest.raises(ElectrumError): + await ElectrumClient("localhost", 1, True, pin).connect() + assert writer.closed + + +async def test_main_builds_clients_with_the_endpoint_pin(): + """ELC-03 / B-84: app.main hands each endpoint's pin to its client.""" + from app.electrum.client import ElectrumEndpoint + from app.main import _make_electrum_client + + assert _make_electrum_client(ElectrumEndpoint("h", 1, True, _PIN)).pin == _PIN + assert _make_electrum_client(ElectrumEndpoint("h", 1, True)).pin is None diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index f6b0aae..fd3216d 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -338,9 +338,11 @@ async def _endpoint_client_factory(responses: dict[str, object]): return factory -async def test_corroborate_header_true_with_no_other_servers_configured(session_factory): +async def test_corroborate_header_true_with_no_other_servers_only_under_allow_no_quorum(session_factory): + """ELC-14 / ELC-19 / B-84: this used to be the rule for everyone. Now only the + development override passes with nothing to corroborate against.""" single = [ElectrumEndpoint("only.example", 50002, True)] - listener = ElectrumListener(lambda endpoint: None, session_factory, single) + listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True) assert await listener.corroborate_header(100, "deadbeef") is True @@ -424,9 +426,10 @@ async def _listunspent_client_factory(responses: dict[str, object]): return factory -async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory): +async def test_corroborate_utxo_spent_true_with_no_other_servers_only_under_allow_no_quorum(session_factory): + """ELC-14 / ELC-19 / B-84, see the header twin of this test.""" single = [ElectrumEndpoint("only.example", 50002, True)] - listener = ElectrumListener(lambda endpoint: None, session_factory, single) + listener = ElectrumListener(lambda endpoint: None, session_factory, single, allow_no_quorum=True) assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True @@ -842,3 +845,86 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory) async with session_factory() as session: user = await session.get(User, user_id) assert user.cached_balance_sats == 25_000_000 + + +# --- B-84 (ELC-14, ELC-19): the quorum is never one server, and never nobody ------ + + +async def test_corroboration_fails_with_no_other_server_configured(session_factory): + """ELC-14 / B-84: nothing to corroborate against is a failure, not a pass.""" + single = [ElectrumEndpoint("only.example", 50002, True)] + listener = ElectrumListener(lambda endpoint: None, session_factory, single) + assert await listener.corroborate_header(100, "deadbeef") is False + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False + + +async def test_a_single_responding_server_does_not_decide(session_factory): + """ELC-14 / B-84: two others are configured but only one answers — even though it + agrees, one answer is not a quorum. Before B-84 this passed.""" + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + factory = await _endpoint_client_factory( + {"first.example": "irrelevant", "second.example": header_hex, "third.example": None} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + assert await listener.corroborate_header(100, expected_hash) is False + + # The development override restores the reduced rule. + relaxed = ElectrumListener(factory, session_factory, _ENDPOINTS, allow_no_quorum=True) + assert await relaxed.corroborate_header(100, expected_hash) is True + + +async def test_two_answers_must_both_agree_for_a_strict_majority(session_factory): + """ELC-14 / B-84: with exactly two answers, 1 agree + 1 disagree is not a majority.""" + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + other_hex = _mine_header("11" * 32) + factory = await _endpoint_client_factory( + {"first.example": "irrelevant", "second.example": header_hex, "third.example": other_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + assert await listener.corroborate_header(100, expected_hash) is False + + +async def test_a_credit_is_not_corroborated_by_one_server_alone(session_factory): + """ELC-17 / B-84: the same minimum applies to every kind of corroboration.""" + entry = {"tx_hash": "77" * 32, "tx_pos": 0, "value": 5_000_000, "height": 10} + factory = await _listunspent_client_factory( + {"first.example": [entry], "second.example": [entry], "third.example": None} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + assert await listener.corroborate_utxo_credit("scripthash", "77" * 32, 0, 5_000_000) is False + + +def test_startup_refuses_fewer_than_two_fallback_servers(): + """ELC-19 / B-84: refuse to serve unless ALLOW_NO_QUORUM=true.""" + import pytest + + from app.config import ConfigError, Settings, validate_quorum_servers + + primary = ElectrumEndpoint("primary.example", 50002, True) + one = [primary, ElectrumEndpoint("a.example", 50002, True)] + two = one + [ElectrumEndpoint("b.example", 50002, True)] + + for too_few in ([primary], one): + with pytest.raises(ConfigError, match="ELECTRUM_FALLBACK_SERVERS"): + validate_quorum_servers(too_few, Settings(allow_no_quorum=False)) + validate_quorum_servers(two, Settings(allow_no_quorum=False)) # must not raise + validate_quorum_servers([primary], Settings(allow_no_quorum=True)) # the override + + +async def test_the_lifespan_refuses_to_serve_without_a_quorum(monkeypatch): + """ELC-19 / ARC-60 / B-84: app.main.lifespan stops before starting any task.""" + import pytest + from fastapi import FastAPI + + from app.config import ConfigError, settings + from app.main import lifespan + + monkeypatch.setattr(settings, "jwt_secret", "x" * 40) + monkeypatch.setattr(settings, "xprv_encryption_key", "k") + monkeypatch.setattr(settings, "electrum_fallback_servers", "only-one.example:50002") + monkeypatch.setattr(settings, "allow_no_quorum", False) + with pytest.raises(ConfigError): + async with lifespan(FastAPI()): + pass