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

@@ -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.
+9 -12
View File
@@ -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
+7 -16
View File
@@ -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:<modo>` con `<modo>` tra `ssl`/`tls` (TLS) e
`notls`/`plain` (in chiaro), oppure `host:porta:pin=<sha256>` (TLS con
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali. Le
certificato fissato, [ELC-03]); `<sha256>` sono 64 cifre esadecimali (maiuscole accettate, conservate in
minuscolo); `pin=` può seguire anche `<modo>` 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… |
|---|---|---|
+13 -16
View File
@@ -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=<sha256>`, `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=<sha256>`). 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`
+3 -10
View File
@@ -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`
+8 -4
View File
@@ -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=<sha256>` (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=<sha256>
```
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
+2 -8
View File
@@ -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
+3 -3
View File
@@ -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`.