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=<sha256>` 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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 14:04:35 +02:00
1 parent fd6a91e93d
commit 13cb66cc06
18 files changed
+451 -125

No files matched your search

+49
View File
@@ -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=<sha256>` 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)"
)