Complete the target spec for the 2026-09-28 review (B-73..B-98)
Finishes what 88221b0 started: sicurezza.md, audit-log.md and storico-bug.md
now describe the target behaviour too, the operator guides are corrected
against today's code, and api.md gains the missing /health/ready and
/admin/electrum sections plus every new error code in its catalog.
Harmonised names and owners across documents (the `resurrected_tx` pause
reason, the reconciler as the one that watches failed rows, the full
`winner_drawn` payload, a reason for an unanswered pre-payout pool check,
one corroboration-failure row per hash instead of one per tick).
Adds B-98 (coin selection stops at the first covering UTXO and refuses a
payable bet), tightens RND-40 to check both 1 and 400 participants and to
guard PUT /admin/config, and gives a stalled `closing` round a signal
(TX-29, closing_stalled).
Records three decisions confirmed on 2026-09-29: the draw's server_seed is
stored Fernet-encrypted until revealed (SEC-56, DAT-64), the chosen abuse
and key-check thresholds (SEC-49, SEC-54, SEC-55), and a broadcast
rejection is verified against every server before any coin is released,
so matching "already known" text is a shortcut, not a safety condition
(TX-04).
Spec only; no code changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88221b0dbd
commit
4be58709d1
16 files changed
+1377
-323
No files matched your search
+514
-125
@@ -1,11 +1,16 @@
|
||||
# Sicurezza
|
||||
|
||||
Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, e
|
||||
quali rischi accetta consapevolmente. Il documento descrive il codice così
|
||||
com'è: dove un meccanismo manca, lo dice.
|
||||
Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, da chi
|
||||
si difende e quali rischi accetta consapevolmente. Il documento descrive il
|
||||
comportamento **atteso** dopo la revisione del 2026-09-28; i requisiti che il
|
||||
codice non soddisfa ancora sono marcati *da implementare*, con una riga
|
||||
*Oggi* che dice cosa fa il codice adesso (vedi
|
||||
[piano-correzioni.md](piano-correzioni.md) e
|
||||
[processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)).
|
||||
|
||||
Per la difesa contro server Electrum ostili (validazione degli header,
|
||||
quorum di corroborazione) vedi [connessione-electrum.md](connessione-electrum.md);
|
||||
per l'estrazione [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione);
|
||||
per gli endpoint e i codici di errore [api.md](api.md); per l'elenco degli
|
||||
eventi di audit [audit-log.md](audit-log.md); per backup e procedure
|
||||
operative [operativita.md](operativita.md); per le decisioni di progetto e i
|
||||
@@ -14,14 +19,17 @@ limiti accettati [decisioni-e-limiti.md](decisioni-e-limiti.md).
|
||||
## Modello di fiducia
|
||||
|
||||
La piattaforma è **custodiale**: tutte le chiavi private sono generate e
|
||||
conservate dal server. L'utente non controlla nulla finché non preleva.
|
||||
conservate dal server. L'utente non controlla nulla finché non preleva
|
||||
([DEC-01](decisioni-e-limiti.md), [LIM-26](decisioni-e-limiti.md)).
|
||||
|
||||
| Soggetto | Fiducia | Perché |
|
||||
|---|---|---|
|
||||
| Operatore (chi ha `ADMIN_TOKEN`) | **Totale** | Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni. |
|
||||
| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra (`.env`). |
|
||||
| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra; separarle in file diversi con permessi stretti ([SEC-51], [SEC-53]) protegge da una copia sbagliata dei dati, non da chi controlla l'host. |
|
||||
| Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. |
|
||||
| Server Electrum | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum: vedi [connessione-electrum.md](connessione-electrum.md#5-il-quorum). |
|
||||
| Server Electrum (attivo e di riserva) | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum di almeno 2 altri server: vedi [Quorum e TLS verso Electrum](#quorum-e-tls-verso-electrum). |
|
||||
| Chi produce i blocchi (minatori, anche con potenza affittata) | Nessuna | Non deve poter scegliere il vincitore né rendere un round impagabile: vedi [Chi produce e trasmette i blocchi](#chi-produce-e-trasmette-i-blocchi). |
|
||||
| Chi trasmette blocchi e transazioni (rete P2P, relay, il server attivo come relay) | Nessuna | Può ritardare, nascondere o riordinare ciò che annuncia; il blocco di estrazione è fissato per altezza e corroborato, e ogni transazione è inviata a tutti i server. |
|
||||
| Rete e client HTTP | Nessuna | TLS terminato da Caddy; l'IP del client è ricavato solo dall'hop scritto da Caddy. |
|
||||
| Caddy (reverse proxy) | Totale | È l'unico proxy davanti all'app, e l'unica fonte di `X-Forwarded-For` di cui l'app si fida. |
|
||||
|
||||
@@ -30,28 +38,105 @@ conservate dal server. L'utente non controlla nulla finché non preleva.
|
||||
generata o conservata lato client.
|
||||
*Codice:* `app/wallet/hd.py:generate_master_key`, `app/wallet/hd.py:_load_account_key`
|
||||
|
||||
### Chi produce e trasmette i blocchi
|
||||
|
||||
L'hash di un blocco è l'unica fonte di casualità esterna al server, ed è
|
||||
prodotto da chi ha la potenza di calcolo. La potenza di PLM, circa 22 TH/s, si
|
||||
può affittare; scartare un blocco valido costa al minatore solo la
|
||||
ricompensa di quel blocco.
|
||||
|
||||
- **Perché il solo hash del blocco era manipolabile.** Se il seme è il solo
|
||||
hash del blocco, chi mina con biglietti in gioco conosce l'esito di ogni
|
||||
blocco che trova *prima* di pubblicarlo, e lo scarta finché uno non lo fa
|
||||
vincere. Chi trasmette i blocchi ha una leva analoga: il server Electrum
|
||||
attivo può non annunciare un blocco e far estrarre dal successivo; la
|
||||
corroborazione verificava che il blocco esistesse, non che fosse il primo
|
||||
dopo la chiusura ([LIM-13](decisioni-e-limiti.md)).
|
||||
- **Come il commit-reveal toglie il movente al minatore.** All'apertura il
|
||||
server si impegna su un segreto pubblicandone solo l'impronta
|
||||
([RND-34](ciclo-di-vita-del-round.md#0--apertura)); all'ingresso in
|
||||
`drawing` si fissano l'elenco dei partecipanti e l'altezza del blocco
|
||||
([RND-33](ciclo-di-vita-del-round.md#2--chiusura)); il seme combina
|
||||
segreto, hash del blocco a quell'altezza ed elenco, e dopo il salvataggio
|
||||
del vincitore il segreto è rivelato con la prova pubblica
|
||||
([RND-37](ciclo-di-vita-del-round.md#3--estrazione)). Un minatore che non
|
||||
conosce il segreto non sa chi vincerebbe con il suo blocco, quindi non ha
|
||||
motivo di scartarlo; un server che trasmette non può scegliere il blocco,
|
||||
perché l'altezza è fissata e l'header a quell'altezza deve essere
|
||||
confermato dal quorum ([ELC-15](connessione-electrum.md#5-il-quorum)).
|
||||
*Oggi* (B-73): il seme è l'hash del primo blocco visto dopo la chiusura,
|
||||
senza segreto né impegno, e l'estrazione è manipolabile da chi mina.
|
||||
- **Header falsi.** Oggi l'unico controllo di lavoro su un header è che il
|
||||
suo hash rispetti il bersaglio che l'header stesso dichiara nel campo
|
||||
`bits`: poiché è chi fabbrica l'header a scegliere `bits`, un header mai
|
||||
minato con `bits = 0x207fffff` lo passa con probabilità 1/2. Dopo B-74 il
|
||||
bersaglio non può essere più facile del powLimit di PLM, `0x1d00ffff`
|
||||
([ELC-11](connessione-electrum.md#4-header-validati-non-creduti)), e ogni
|
||||
salto di più blocchi è verificato come catena. Anche al powLimit un header
|
||||
costa in media 2³² hash, pochi millisecondi a un ASIC: il controllo toglie
|
||||
la contraffazione gratuita, non quella economica. A proteggere l'estrazione
|
||||
sono il quorum sull'header di estrazione e il segreto del commit-reveal
|
||||
([LIM-14](decisioni-e-limiti.md)).
|
||||
|
||||
Restano **accettati**, e descritti in
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md):
|
||||
|
||||
- **Operatore d'accordo con un minatore** ([LIM-25](decisioni-e-limiti.md)).
|
||||
L'operatore conosce il segreto prima della rivelazione e può calcolare
|
||||
l'esito di ogni blocco candidato; un minatore complice può scartare quelli
|
||||
sfavorevoli. È accettato perché l'operatore è già fidato come custode di
|
||||
tutti i fondi ([LIM-26](decisioni-e-limiti.md)): chi può leggere il segreto
|
||||
dal database può già spendere ogni moneta.
|
||||
- **Un attacco al 51% su PLM** ([LIM-24](decisioni-e-limiti.md)) è
|
||||
contenuto, non impedito: 6 conferme sui depositi
|
||||
([DEC-06](decisioni-e-limiti.md), B-82), rilevamento delle
|
||||
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
|
||||
del round prima del pagamento ([DEC-52](decisioni-e-limiti.md), B-82,
|
||||
B-85). Il denaro che un attaccante riesce a sottrarre è perso per la
|
||||
piattaforma. *Oggi:* depositi a 1 conferma, nessuna riorganizzazione
|
||||
rilevata, nessuna verifica prima del pagamento.
|
||||
|
||||
## Inventario dei segreti
|
||||
|
||||
| Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione |
|
||||
|---|---|---|---|---|---|
|
||||
| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza `XPRV_ENCRYPTION_KEY`; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa |
|
||||
| `XPRV_ENCRYPTION_KEY` | `.env` (variabile d'ambiente del container) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale: decifrare con la chiave vecchia, impostare la nuova, `encrypt_master_key.py --overwrite`. Non esiste uno script dedicato |
|
||||
| `JWT_SECRET` | `.env` | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni |
|
||||
| `ADMIN_TOKEN` | `.env`; nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token |
|
||||
| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`), permessi `0600` ([SEC-51]) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza la chiave di cifratura; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa |
|
||||
| Chiave di cifratura della master key (Fernet) | File `./secrets/xprv_encryption.key` (permessi `0600`, fuori da `./data/`), montato in sola lettura come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da `XPRV_ENCRYPTION_KEY_FILE` ([SEC-53]) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale, con la procedura [OPS-22](operativita.md#segreto-compromesso). Non esiste uno script dedicato |
|
||||
| `JWT_SECRET` | `.env` (permessi `0600`) | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni |
|
||||
| `ADMIN_TOKEN` | `.env` (permessi `0600`); nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token |
|
||||
| Segreto del round (`server_seed`) | Tabella `rounds`, **cifrato** con la chiave della master xprv (`server_seed_enc`) fino alla rivelazione, poi in chiaro ([RND-34](ciclo-di-vita-del-round.md#0--apertura), [SEC-56]) | 32 byte da un generatore crittograficamente sicuro, all'apertura del round | Prima della rivelazione permette di calcolare il vincitore di ogni blocco candidato ([LIM-25](decisioni-e-limiti.md)) | L'estrazione di quel round non può concludersi | Uno nuovo per ogni round; pubblico dopo la rivelazione |
|
||||
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
|
||||
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
|
||||
|
||||
- **[SEC-02]** I segreti (`XPRV_ENCRYPTION_KEY`, `JWT_SECRET`, `ADMIN_TOKEN`)
|
||||
stanno solo in `.env`, mai nel database o nel repository; `.env`, i file
|
||||
*Oggi* (B-91, B-73): la chiave di cifratura è la variabile d'ambiente
|
||||
`XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect`
|
||||
sul container; `.env` e i file di chiave hanno i permessi predefiniti del
|
||||
sistema (di norma `0644`, leggibili da tutti); il round non ha alcun segreto.
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le
|
||||
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
|
||||
l'host.
|
||||
|
||||
- **[SEC-02]** I segreti stanno fuori dal database e dal repository:
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` in `.env`, la chiave di cifratura della master
|
||||
key nel proprio file ([SEC-53]). `.env`, la cartella `secrets/`, i file
|
||||
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
|
||||
git sia dall'immagine Docker.
|
||||
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
|
||||
*Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di
|
||||
`secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env`
|
||||
(`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono
|
||||
`secrets/`.
|
||||
|
||||
- **[SEC-03]** Tutti i parametri operativi stanno nel database
|
||||
(`round_config`); in `.env` restano solo segreti e collegamenti
|
||||
infrastrutturali (database, percorso della master key, server Electrum,
|
||||
documentazione API). Cambiare un valore di `.env` richiede un riavvio.
|
||||
(`round_config`); in `.env` restano solo segreti, collegamenti
|
||||
infrastrutturali (database, percorso della master key, file della chiave
|
||||
di cifratura, server Electrum) e flag di avvio (`ENABLE_API_DOCS`,
|
||||
`ALLOW_NO_QUORUM`, `START_PAUSED`). Cambiare un valore di `.env` richiede un
|
||||
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.
|
||||
|
||||
`.env.example` documenta le chiavi necessarie e il comando per generarle;
|
||||
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
|
||||
@@ -62,11 +147,12 @@ sovrascritte da `docker-compose.yml`.
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G["generate_master_key.py<br/>seed casuale 32 byte"] --> E
|
||||
I["encrypt_master_key.py<br/>xprv importata (input nascosto)"] --> E
|
||||
E["Cifratura Fernet<br/>con XPRV_ENCRYPTION_KEY"] --> F[("master.xprv.enc<br/>./data/keys/")]
|
||||
F --> L["Primo uso a runtime:<br/>decifratura e derivazione<br/>di m/84'/746'/0'"]
|
||||
I["encrypt_master_key.py<br/>xprv importata (input nascosto)<br/>confrontata con il database (SEC-50)"] --> E
|
||||
E["Cifratura Fernet<br/>con la chiave di XPRV_ENCRYPTION_KEY_FILE"] --> F[("master.xprv.enc<br/>./data/keys/, 0600")]
|
||||
F --> L["Avvio del server:<br/>decifratura, derivazione di m/84'/746'/0'<br/>e confronto con il database (SEC-49)"]
|
||||
L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
|
||||
L --> P["Montepremi<br/>m/84'/746'/0'/1/0"]
|
||||
L --> P["Montepremi del round<br/>m/84'/746'/0'/2/round_id"]
|
||||
L --> R["Riserva del montepremi<br/>m/84'/746'/0'/1/0"]
|
||||
F --> D["decrypt_master_key.py<br/>stampa la xprv in chiaro<br/>(solo recupero)"]
|
||||
```
|
||||
|
||||
@@ -76,28 +162,60 @@ flowchart TD
|
||||
API: `generate_master_key.py` genera un seed casuale di 32 byte, ne ricava
|
||||
la xprv (versione PLM mainnet), la cifra e la scrive in `MASTER_KEY_PATH`.
|
||||
Rifiuta di sovrascrivere un file esistente; entrambi gli script di
|
||||
creazione rifiutano di partire se `XPRV_ENCRYPTION_KEY` è vuota.
|
||||
creazione rifiutano di partire se la chiave di cifratura è vuota. I
|
||||
controlli sul database e i permessi del file scritto sono in [SEC-50].
|
||||
*Codice:* `app/wallet/hd.py:generate_master_key`, `scripts/generate_master_key.py`
|
||||
|
||||
- **[SEC-05]** `encrypt_master_key.py` importa una xprv generata altrove: la
|
||||
legge con input nascosto (`getpass`), verifica che sia una chiave estesa
|
||||
valida e **privata** (rifiuta una xpub), e la cifra con lo stesso schema.
|
||||
Non può verificare che sia una chiave PLM: PLM usa gli stessi byte di
|
||||
versione xprv di Bitcoin. Sovrascrive un file esistente solo con
|
||||
`--overwrite`.
|
||||
I byte di versione non bastano a riconoscere una chiave PLM (PLM usa gli
|
||||
stessi byte di versione xprv di Bitcoin): a riconoscere la chiave giusta è
|
||||
il confronto con gli indirizzi già salvati ([SEC-50]). Sovrascrive un file
|
||||
esistente solo con `--overwrite`.
|
||||
*Codice:* `scripts/encrypt_master_key.py`
|
||||
|
||||
- **[SEC-50]** <a id="sec-50"></a>Gli script che scrivono la master key
|
||||
controllano il database indicato da `DATABASE_URL` prima di scrivere:
|
||||
`generate_master_key.py` rifiuta se il database contiene già utenti o
|
||||
round, perché una chiave nuova non ne riprodurrebbe gli indirizzi;
|
||||
`encrypt_master_key.py` ricalcola dalla xprv ricevuta gli indirizzi del
|
||||
campione di [SEC-49] e rifiuta, anche con `--overwrite`, se uno solo
|
||||
differisce da quello salvato. Un database assente o vuoto non ha nulla da
|
||||
confrontare e non blocca. Entrambi gli script creano il file con permessi
|
||||
`0600` (senza passare da permessi più larghi) e lo riscrivono con gli
|
||||
stessi permessi.
|
||||
*Codice:* `scripts/generate_master_key.py`, `scripts/encrypt_master_key.py`,
|
||||
`app/wallet/hd.py:generate_master_key` · *Test:* —
|
||||
*Stato:* da implementare (B-89) · *Oggi:* gli script accettano qualunque
|
||||
xprv senza guardare il database e scrivono il file con i permessi
|
||||
predefiniti del sistema (di norma `0644`).
|
||||
|
||||
### Cifratura a riposo
|
||||
|
||||
- **[SEC-06]** Il file della master key contiene la xprv in base58 cifrata con
|
||||
**Fernet** (AES-128-CBC + HMAC-SHA256) usando `XPRV_ENCRYPTION_KEY`. La xprv
|
||||
in chiaro non viene mai scritta su disco dal server.
|
||||
**Fernet** (AES-128-CBC + HMAC-SHA256) usando la chiave di cifratura
|
||||
([SEC-53]). La xprv in chiaro non viene mai scritta su disco dal server.
|
||||
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/keystore.py:decrypt_xprv`
|
||||
|
||||
La cifratura protegge da una copia del solo file (un backup di `data/`
|
||||
finito nel posto sbagliato), **non** da chi ha accesso all'host: la chiave di
|
||||
decifratura sta nel `.env` accanto. Gli script non impostano permessi
|
||||
restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
||||
decifratura sta sullo stesso host. Tenerla fuori da `./data/` ([SEC-53]) fa
|
||||
sì che un backup dei dati non contenga entrambe.
|
||||
|
||||
- **[SEC-53]** <a id="sec-53"></a>La chiave di cifratura della master key è
|
||||
un **file**, `./secrets/xprv_encryption.key` sull'host (cartella `0700`,
|
||||
file `0600`, fuori da `./data/`), montato in sola lettura come secret di
|
||||
compose in `/run/secrets/xprv_encryption_key`; l'app legge il percorso da
|
||||
`XPRV_ENCRYPTION_KEY_FILE` ([architettura.md](architettura.md#configurazione),
|
||||
ARC-05, ARC-30). La chiave non compare in alcuna variabile d'ambiente del
|
||||
container, quindi `docker inspect` non la mostra, e non finisce in un
|
||||
backup di `./data/`.
|
||||
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
|
||||
`docker-compose.yml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile
|
||||
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
|
||||
visibile con `docker inspect`.
|
||||
|
||||
### Derivazione
|
||||
|
||||
@@ -108,19 +226,26 @@ restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
||||
`app/wallet/plm_network.py` ·
|
||||
*Test:* `tests/unit/test_hd.py`
|
||||
|
||||
- **[SEC-08]** La chiave del montepremi è `m/84'/746'/0'/1/0` (ramo 1
|
||||
dell'account, indice 0): sta nella stessa master key, non è un segreto
|
||||
separato. Non esiste un endpoint che ne esporti la chiave privata.
|
||||
- **[SEC-08]** Le chiavi del montepremi — una per round,
|
||||
`m/84'/746'/0'/2/<round_id>`, e quella di riserva, `m/84'/746'/0'/1/0`
|
||||
([REG-14](registrazione-e-depositi.md#il-conto-del-montepremi)) — stanno
|
||||
nella stessa master key, non sono segreti separati. Non esiste un endpoint
|
||||
che ne esporti la chiave privata.
|
||||
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`
|
||||
*Stato:* da implementare (B-80), per le chiavi per round · *Oggi:* esiste
|
||||
una sola chiave del montepremi, `m/84'/746'/0'/1/0`, per tutti i round.
|
||||
|
||||
### Caricamento a runtime
|
||||
|
||||
- **[SEC-09]** La master key viene letta e decifrata **al primo uso** (la
|
||||
prima derivazione, tipicamente una registrazione o il primo pagamento), non
|
||||
all'avvio. Da quel momento la chiave dell'account `m/84'/746'/0'` resta in
|
||||
memoria nel processo per tutta la sua durata; la root xprv non viene
|
||||
conservata.
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key`
|
||||
- **[SEC-09]** La master key viene letta e decifrata **all'avvio**, nel
|
||||
lifespan, prima di creare le attività in background, ed è subito
|
||||
verificata ([SEC-49]). Da quel momento la chiave dell'account
|
||||
`m/84'/746'/0'` resta in memoria nel processo per tutta la sua durata; la
|
||||
root xprv non viene conservata.
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan`
|
||||
*Stato:* da implementare (B-89) · *Oggi:* la chiave è decifrata al primo
|
||||
uso (la prima derivazione, tipicamente una registrazione o il primo
|
||||
pagamento), non all'avvio.
|
||||
|
||||
### Recupero
|
||||
|
||||
@@ -132,32 +257,67 @@ restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
||||
|
||||
## Regole di avvio
|
||||
|
||||
L'ordine completo dei controlli di avvio è in
|
||||
[architettura.md](architettura.md#nel-lifespan) (ARC-19, ARC-60); qui ci sono
|
||||
quelli di sicurezza. Il rifiuto di partire con meno di 2 server Electrum di
|
||||
riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
|
||||
[Quorum e TLS verso Electrum](#quorum-e-tls-verso-electrum)).
|
||||
|
||||
- **[SEC-11]** Il container rifiuta di partire se il file della master key non
|
||||
esiste in `MASTER_KEY_PATH`, prima ancora delle migrazioni.
|
||||
*Codice:* `Dockerfile` (comando `CMD`)
|
||||
|
||||
- **[SEC-12]** All'avvio il server **rifiuta di servire** se `JWT_SECRET` ha
|
||||
meno di 32 caratteri o se `XPRV_ENCRYPTION_KEY` è vuota; tutti i problemi
|
||||
sono riportati insieme in un unico `ConfigError`. Il controllo è nel
|
||||
lifespan, non nella classe `Settings`, perché test e script importano la
|
||||
configurazione senza avere segreti reali.
|
||||
- **[SEC-12]** <a id="sec-12"></a>All'avvio il server **rifiuta di servire**
|
||||
se `JWT_SECRET` ha meno di 32 caratteri o se la chiave di cifratura della
|
||||
master key è vuota (il file indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o
|
||||
è vuoto); tutti i problemi sono riportati insieme in un unico
|
||||
`ConfigError`. Il controllo è nel lifespan, non nella classe `Settings`,
|
||||
perché test e script importano la configurazione senza avere segreti
|
||||
reali.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
|
||||
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
|
||||
|
||||
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
||||
risulta semplicemente chiuso (vedi [SEC-24]).
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
|
||||
Il controllo di avvio verifica che `XPRV_ENCRYPTION_KEY` non sia vuota, non
|
||||
che sia una chiave Fernet valida né che decifri davvero il file: una chiave
|
||||
malformata o sbagliata emerge solo al primo uso della master key ([SEC-09]).
|
||||
- **[SEC-49]** <a id="sec-49"></a>Dopo [SEC-12], il server decifra la master
|
||||
key ([SEC-09]) e ne ricava gli indirizzi di un
|
||||
campione fisso, confrontandoli con quelli salvati nel database:
|
||||
- l'indirizzo di riserva `m/84'/746'/0'/1/0`, con il `pool_address` di ogni
|
||||
round che lo usa;
|
||||
- gli indirizzi degli utenti con i 10 `derivation_index` più bassi e i 10
|
||||
più alti (tutti, se gli utenti sono meno di 20), con `users.address`;
|
||||
- gli indirizzi `m/84'/746'/0'/2/<round_id>` degli ultimi 10 round per `id`
|
||||
che hanno un indirizzo proprio, con il loro `pool_address`;
|
||||
- il `server_seed_enc` di ogni round non ancora rivelato, che deve
|
||||
decifrarsi e avere `sha256` uguale al suo `server_seed_hash` ([SEC-56]).
|
||||
|
||||
Se la chiave non si decifra (chiave di cifratura sbagliata o malformata,
|
||||
file corrotto) o **un solo** indirizzo differisce, il server **rifiuta di
|
||||
servire**: il lifespan scrive nel log quale controllo è fallito (tipo e
|
||||
indice o id, mai la chiave) e il processo termina. Un database senza
|
||||
utenti né round non ha nulla da confrontare: basta che la chiave si
|
||||
decifri. Lo scopo è non partire con una master key diversa da quella che
|
||||
ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il
|
||||
montepremi e farebbe firmare transazioni che la rete rifiuta.
|
||||
*Codice:* `app/main.py:lifespan`, `app/wallet/hd.py:_load_account_key`,
|
||||
`app/wallet/hd.py:derive_user_address`, `app/wallet/hd.py:derive_pool_address` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-89) · *Oggi:* all'avvio non si decifra nulla;
|
||||
una chiave di cifratura sbagliata o malformata emerge solo al primo uso
|
||||
della master key (prima registrazione o primo pagamento), e una xprv
|
||||
diversa da quella originale non emerge affatto: produce indirizzi nuovi,
|
||||
compreso quello del montepremi.
|
||||
|
||||
## Autenticazione degli utenti
|
||||
|
||||
Il flusso di registrazione e login è descritto in
|
||||
[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione);
|
||||
qui ci sono le regole di sicurezza.
|
||||
Il flusso di registrazione, login e cambio password è descritto in
|
||||
[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione),
|
||||
che ne è il proprietario; qui ci sono le regole di sicurezza.
|
||||
|
||||
### Password
|
||||
|
||||
@@ -181,37 +341,45 @@ qui ci sono le regole di sicurezza.
|
||||
*Codice:* `app/auth/security.py:verify_password` ·
|
||||
*Test:* `tests/unit/test_security.py`
|
||||
|
||||
- **[SEC-17]** Lunghezza minima della password: 8 caratteri
|
||||
(`MIN_PASSWORD_LENGTH`), la stessa costante per registrazione e cambio
|
||||
password. Alla registrazione la password ha anche un massimo di 256
|
||||
caratteri; il cambio password e il login non impongono un massimo.
|
||||
- **[SEC-17]** Soglie delle credenziali: una password nuova ha almeno 8
|
||||
caratteri (`MIN_PASSWORD_LENGTH`, la stessa costante per registrazione e
|
||||
cambio password), e **ogni** campo di credenziali — username e password
|
||||
alla registrazione e al login, password attuale e nuova al cambio
|
||||
password — ha al massimo 256 caratteri. Il massimo limita il lavoro di
|
||||
Argon2 e la memoria che una sola richiesta può chiedere. Come ciascun
|
||||
endpoint le applica, e con quale errore, è in
|
||||
[REG-01](registrazione-e-depositi.md#registrazione),
|
||||
[REG-09](registrazione-e-depositi.md#sessione-e-password) e
|
||||
[REG-12](registrazione-e-depositi.md#sessione-e-password).
|
||||
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
|
||||
`app/api/routes/users.py:change_password` ·
|
||||
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per il massimo su login e cambio password ·
|
||||
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
|
||||
i campi del login e del cambio password non hanno limite di lunghezza.
|
||||
|
||||
- **[SEC-18]** Lo username è di 3–32 caratteri, solo `A-Z a-z 0-9 _ . -`, e
|
||||
**non distingue maiuscole e minuscole**: l'unicità è garantita da un indice
|
||||
unico su `lower(username)` e registrazione e login confrontano
|
||||
`lower(...)`. Il nome resta salvato come digitato.
|
||||
*Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:register`,
|
||||
`app/auth/routes.py:login`, `app/db/models.py:User` ·
|
||||
- ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione)
|
||||
e [REG-02](registrazione-e-depositi.md#registrazione): formato dello
|
||||
username, confronto senza maiuscole e indice unico su `lower(username)` sono
|
||||
regole del dominio, con la motivazione in [DEC-38](decisioni-e-limiti.md);
|
||||
qui non resta nulla di specifico della sicurezza.
|
||||
|
||||
- **[SEC-19]** Login con username inesistente e con password errata danno la
|
||||
stessa risposta ([REG-09](registrazione-e-depositi.md#sessione-e-password))
|
||||
e alimentano lo stesso contatore di tentativi ([SEC-30]). Due canali
|
||||
rivelano comunque se uno username esiste, e sono accettati: se lo username
|
||||
non esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno
|
||||
tempi di risposta diversi; la registrazione risponde per costruzione
|
||||
`username_taken` (409) a uno username occupato.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:register` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
|
||||
- **[SEC-19]** Il login risponde allo stesso modo (401 `invalid_credentials`)
|
||||
sia per uno username inesistente sia per una password errata, e i due casi
|
||||
alimentano lo stesso contatore di tentativi. Nota: se lo username non
|
||||
esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi
|
||||
di risposta diversi. La registrazione, invece, rivela per costruzione se uno
|
||||
username è occupato (409 `username_taken`).
|
||||
*Codice:* `app/auth/routes.py:login` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
|
||||
- **[SEC-20]** Non esiste recupero della password né reset self-service
|
||||
(nessuna email è raccolta). Un utente autenticato può *cambiarla* solo
|
||||
fornendo quella attuale; altrimenti serve il reset dall'operatore
|
||||
([SEC-27]).
|
||||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
- ~~[SEC-20]~~ Assorbito da [REG-12](registrazione-e-depositi.md#sessione-e-password)
|
||||
e [REG-13](registrazione-e-depositi.md#sessione-e-password): l'assenza di
|
||||
recupero, il cambio con la password attuale e il reset dall'operatore sono
|
||||
il flusso del dominio (motivazione in [DEC-37](decisioni-e-limiti.md)); la
|
||||
parte di sicurezza, cioè il freno sui tentativi, è [SEC-54], e il reset è
|
||||
[SEC-27].
|
||||
|
||||
### Token di sessione (JWT)
|
||||
|
||||
@@ -230,10 +398,15 @@ qui ci sono le regole di sicurezza.
|
||||
dell'operatore incrementano `token_version`, invalidando ogni sessione
|
||||
precedente; il cambio password restituisce un token nuovo, così la sessione
|
||||
corrente resta attiva. Un token privo di `tv` (emesso prima della sua
|
||||
introduzione) vale come `tv = 0`.
|
||||
introduzione) vale come `tv = 0`. Una richiesta senza header
|
||||
`Authorization` risponde 401 `not_authenticated`
|
||||
([API-06](api.md#convenzioni-generali)).
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
|
||||
`app/api/routes/admin.py:reset_user_password` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:*
|
||||
l'header assente risponde 401 con il corpo non strutturato di FastAPI
|
||||
`{"detail": "Not authenticated"}`.
|
||||
|
||||
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
|
||||
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
|
||||
@@ -250,10 +423,13 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
|
||||
- **[SEC-24]** Ogni endpoint `/admin/*` confronta `X-Admin-Token` con
|
||||
`ADMIN_TOKEN` in tempo costante (`secrets.compare_digest` sui byte UTF-8,
|
||||
così un token con caratteri non ASCII dà 403 e non 500). Se `ADMIN_TOKEN` è
|
||||
vuoto **ogni** richiesta è rifiutata con 403, prima del confronto (che su due
|
||||
stringhe vuote darebbe esito positivo).
|
||||
vuoto **ogni** richiesta è rifiutata, prima del confronto (che su due
|
||||
stringhe vuote darebbe esito positivo). Il rifiuto è 403 con codice
|
||||
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo
|
||||
non strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
|
||||
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
|
||||
@@ -311,14 +487,16 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
| Login per username | `user:<username minuscolo>` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` |
|
||||
| Login per IP | `ip:<ip>` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` |
|
||||
| Registrazione per IP | `ip:<ip>` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` |
|
||||
| Cambio password per utente *(B-94, da implementare)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
|
||||
| Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
|
||||
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
|
||||
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
|
||||
|
||||
- **[SEC-30]** Il login è bloccato se **uno qualunque** dei due limitatori
|
||||
(username o IP) è in blocco, anche con la password giusta; la risposta è
|
||||
429 `rate_limited` con `retry_after_seconds` nei `params`. Ogni fallimento
|
||||
alimenta entrambi i limitatori; un successo azzera solo quello dello
|
||||
username.
|
||||
- **[SEC-30]** <a id="sec-30"></a>Il login è bloccato se **uno qualunque**
|
||||
dei due limitatori (username o IP) è in blocco, anche con la password
|
||||
giusta; la risposta è 429 `rate_limited` con `retry_after_seconds` nei
|
||||
`params`. Ogni fallimento alimenta entrambi i limitatori; un successo
|
||||
azzera solo quello dello username.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter`,
|
||||
`app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
@@ -331,6 +509,37 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
*Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
|
||||
- **[SEC-54]** Il cambio password ha un freno sui fallimenti per utente, con
|
||||
le soglie della tabella: ogni `current_password` sbagliata registra un
|
||||
fallimento sulla chiave `user:<id utente>` di un `RateLimiter` dedicato
|
||||
(soglia 5, attesa iniziale 2s, massima 300s, lo stesso modello del login
|
||||
per username), un cambio riuscito lo azzera. Il blocco è verificato
|
||||
**prima** dell'hash Argon2 e anche con la password giusta; la risposta è
|
||||
429 `rate_limited` con `retry_after_seconds` nei `params`. Serve a chi ha
|
||||
rubato un token valido e vuole scoprire la password, per poi cambiarla e
|
||||
chiudere fuori il proprietario. L'evento `password_changed` e l'ordine
|
||||
degli errori sono di
|
||||
[REG-12](registrazione-e-depositi.md#sessione-e-password) e
|
||||
[API-21](api.md).
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido
|
||||
la password attuale si può provare senza limiti.
|
||||
|
||||
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
|
||||
([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile,
|
||||
un `RollingQuota` dedicato), anche per un chiamante autenticato. Il
|
||||
controllo avviene prima dell'inserimento; un invio rifiutato per
|
||||
validazione non consuma la quota. Oltre la quota: 429 `rate_limited` con
|
||||
l'attesa esatta fino all'uscita dalla finestra della segnalazione più
|
||||
vecchia ([API-28](api.md)).
|
||||
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può
|
||||
inserire segnalazioni senza limite, fino a riempire il database.
|
||||
|
||||
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
|
||||
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
|
||||
chiavi; oltre, si scartano le voci più vicine alla scadenza, mai i blocchi
|
||||
@@ -340,25 +549,30 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
|
||||
- **[SEC-33]** Il canale SSE accetta al massimo 5 stream contemporanei per IP
|
||||
(il sesto chiude il più vecchio dello stesso IP invece di essere
|
||||
rifiutato) e 500 in totale (oltre, 503; il browser ripiega sul polling).
|
||||
rifiutato) e 500 in totale (oltre, 503 `too_many_streams`,
|
||||
[API-16](api.md#contratto-degli-errori); il browser ripiega sul polling).
|
||||
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/api/routes/rounds.py:round_stream` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94), per il codice `too_many_streams` ·
|
||||
*Oggi:* il 503 ha il corpo non strutturato
|
||||
`{"detail": "too many concurrent update streams"}`.
|
||||
|
||||
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, cambio
|
||||
password (tentativi sulla password attuale compresi), endpoint admin
|
||||
(tentativi sul token compresi), invio di bug report (anche anonimi), QR,
|
||||
`GET /rounds/current` e gli altri endpoint di lettura. Scommesse e prelievi
|
||||
sono serializzati per utente dal blocco per-utente (vedi
|
||||
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi,
|
||||
endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e
|
||||
gli altri endpoint di lettura. Scommesse e prelievi sono serializzati per
|
||||
utente dal blocco per-utente (vedi
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme)),
|
||||
che non è un limite di frequenza.
|
||||
che non è un limite di frequenza ([LIM-07](decisioni-e-limiti.md)).
|
||||
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
|
||||
`app/api/routes/users.py:change_password`, `app/api/routes/bug_reports.py:create_bug_report`
|
||||
`app/api/routes/admin.py:require_admin`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e
|
||||
l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]).
|
||||
|
||||
## Indirizzo IP del client
|
||||
|
||||
Ogni controllo basato sull'IP (limitatori di login e registrazione, limite
|
||||
SSE per IP) usa la stessa funzione.
|
||||
Ogni controllo basato sull'IP (limitatori di login, registrazione e
|
||||
segnalazioni di bug, limite SSE per IP) usa la stessa funzione.
|
||||
|
||||
- **[SEC-35]** L'IP del client è l'**ultimo** elemento di `X-Forwarded-For`
|
||||
(quello scritto dall'hop più vicino, cioè Caddy), non il primo, che il
|
||||
@@ -380,11 +594,90 @@ SSE per IP) usa la stessa funzione.
|
||||
l'ultimo hop di `X-Forwarded-For`.
|
||||
*Codice:* `docker-compose.yml`
|
||||
|
||||
## Quorum e TLS verso Electrum
|
||||
|
||||
Le regole sono di [connessione-electrum.md](connessione-electrum.md); qui il
|
||||
loro significato per la sicurezza.
|
||||
|
||||
- **Quorum minimo.** Il server rifiuta di avviarsi con meno di 2 server di
|
||||
riserva oltre al principale
|
||||
([ELC-19](connessione-electrum.md#1-rotazione-tra-server)), e ogni
|
||||
corroborazione richiede almeno 2 risposte e la maggioranza stretta di chi
|
||||
risponde ([ELC-14](connessione-electrum.md#5-il-quorum)). Il server attivo
|
||||
non è mai interrogato come testimone di sé stesso. Con meno di due voci
|
||||
indipendenti, chi controlla il server attivo — o l'unico server di
|
||||
riserva raggiungibile — sceglierebbe da solo il blocco di estrazione, gli
|
||||
accrediti, le monete date per spese e le conferme. Una rete in cui
|
||||
rispondono meno di 2 server ritarda le decisioni, non le prende
|
||||
([DEC-07](decisioni-e-limiti.md)).
|
||||
- **`ALLOW_NO_QUORUM=true`** riporta la regola ridotta (nessun altro server:
|
||||
la verifica passa; altrimenti basta una risposta). Rende fidato il server
|
||||
attivo ed è **solo per lo sviluppo**; ogni avvio lo scrive nel log come
|
||||
avvertimento ([ELC-19](connessione-electrum.md#1-rotazione-tra-server)).
|
||||
- **TLS e pin del certificato.** Il certificato di un server Electrum non è
|
||||
verificato contro un'autorità, perché i server usano spesso certificati
|
||||
autofirmati. Può però essere **fissato**: `pin=<sha256>` su una voce di
|
||||
`ELECTRUM_FALLBACK_SERVERS` (`host:porta[:notls][:pin=<sha256>]`) ed
|
||||
`ELECTRUM_CERT_PIN` per il principale; un certificato diverso fa fallire
|
||||
la connessione come un server irraggiungibile
|
||||
([ELC-01](connessione-electrum.md#1-rotazione-tra-server),
|
||||
[ELC-03](connessione-electrum.md#1-rotazione-tra-server)). Senza pin, chi
|
||||
si mette in mezzo alla connessione verso un server ne prende il posto; il
|
||||
pin toglie questa possibilità, non quella di un server ostile in sé, contro
|
||||
cui resta il quorum. Una voce `notls` non ha né cifratura né pin, e conta
|
||||
nel quorum solo come server indipendente.
|
||||
- **Resta accettato** che i server del quorum siano scelti dall'operatore:
|
||||
più server controllati dalla stessa parte contano come voci indipendenti
|
||||
([LIM-11](decisioni-e-limiti.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
|
||||
fiducia](#modello-di-fiducia)): queste regole non lo cambiano, ma riducono
|
||||
ciò che ottiene chi entra con meno privilegi (un altro utente dell'host, un
|
||||
processo nel container, una copia di `./data/`) o chi manomette ciò che
|
||||
entra nell'immagine.
|
||||
|
||||
- **[SEC-51]** <a id="sec-51"></a>Il processo del container `app` gira come
|
||||
un utente **non root** dedicato, dichiarato con `USER` nel `Dockerfile`.
|
||||
Sull'host `./data/`, le sue sottocartelle e `./secrets/` hanno permessi
|
||||
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
|
||||
`0600`; tutti appartengono all'utente con cui gira il container. I file che
|
||||
l'app crea (database, backup, log) nascono con permessi `0600`.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i
|
||||
file che scrive in `./data/` appartengono a root sull'host, e `.env` e i
|
||||
file di chiave hanno i permessi predefiniti del sistema (di norma `0644`,
|
||||
leggibili da ogni utente dell'host).
|
||||
|
||||
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
|
||||
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
|
||||
per digest (`@sha256:…`), e le dipendenze Python si installano da un file
|
||||
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
|
||||
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
|
||||
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* —
|
||||
*Stato:* da implementare (B-91) · *Oggi:* le immagini di base sono
|
||||
indicate per etichetta mobile e le dipendenze con `>=`: ogni build può
|
||||
installare versioni diverse, comprese versioni compromesse pubblicate dopo
|
||||
l'ultima build.
|
||||
|
||||
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
|
||||
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
|
||||
proprietario ([operativita.md](operativita.md#topologia)).
|
||||
|
||||
## TLS e header HTTP
|
||||
|
||||
- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato ottiene e
|
||||
rinnova un certificato Let's Encrypt; con il valore predefinito
|
||||
`localhost` usa un certificato autofirmato della propria CA interna.
|
||||
- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato a un dominio
|
||||
ottiene e rinnova un certificato Let's Encrypt; con `localhost` usa un
|
||||
certificato autofirmato della propria CA interna
|
||||
([ARC-01](architettura.md#panoramica-del-deployment)).
|
||||
*Codice:* `Caddyfile`, `docker-compose.yml`
|
||||
|
||||
- **[SEC-39]** Caddy aggiunge a ogni risposta questi header:
|
||||
@@ -408,10 +701,20 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
`style.css`/`admin.css`). I dati provenienti dal server inseriti con
|
||||
`innerHTML` nei due pannelli passano da una funzione `escapeHtml`.
|
||||
|
||||
- **[SEC-40]** Le pagine HTML (`/`, `/admin`, `/guida`, `/report-bug`) sono
|
||||
servite con `Cache-Control: no-store`. Non è configurato alcun middleware
|
||||
CORS: il browser consente le chiamate all'API solo dalla stessa origine.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`
|
||||
- **[SEC-40]** Il browser non usa mai una pagina o uno script di una
|
||||
versione precedente senza chiederne conferma al server: le pagine HTML
|
||||
(`/`, `/admin`, `/guida`, `/report-bug`) sono servite con
|
||||
`Cache-Control: no-store`, i file statici (JS, CSS) con
|
||||
`Cache-Control: no-cache` ([API-09](api.md#convenzioni-generali)), così
|
||||
una correzione di sicurezza nel frontend arriva al primo caricamento dopo
|
||||
il deploy. Non è configurato alcun middleware CORS: il browser consente le
|
||||
chiamate all'API solo dalla stessa origine.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount di `app/static/` non hanno `Cache-Control`, solo
|
||||
`ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con
|
||||
una pagina nuova.
|
||||
|
||||
## Superficie esposta
|
||||
|
||||
@@ -423,8 +726,15 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
|
||||
- **[SEC-42]** Un errore non gestito risponde 500 con il formato di errore
|
||||
standard (`internal_error`) e **senza** il testo dell'eccezione, che resta
|
||||
solo in `logs/app.log`.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`
|
||||
solo in `logs/app.log`. Anche ogni altra risposta d'errore — admin, SSE,
|
||||
validazione e framework compresi — ha il formato strutturato con un codice
|
||||
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
|
||||
così una password troppo lunga non torna indietro in chiaro.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
|
||||
*Stato:* da implementare (B-94), per gli errori diversi dal 500 ·
|
||||
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
|
||||
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
|
||||
`input` il valore ricevuto.
|
||||
|
||||
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
|
||||
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
@@ -435,10 +745,15 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
`fee_address`, QR) è accettato solo se inizia con `plm1` **e** supera la
|
||||
decodifica bech32 con checksum: un indirizzo valido di un'altra catena
|
||||
(`bc1…`) sarebbe altrimenti uno script valido su cui i fondi andrebbero
|
||||
persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato.
|
||||
persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato, e
|
||||
così quello verso un indirizzo del montepremi, di riserva o delle
|
||||
commissioni ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo),
|
||||
B-80).
|
||||
*Codice:* `app/wallet/address.py:is_valid_plm_address`,
|
||||
`app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py`
|
||||
*Stato:* da implementare (B-80), per gli indirizzi di montepremi, riserva
|
||||
e commissioni · *Oggi:* è rifiutato solo il proprio indirizzo di deposito.
|
||||
|
||||
- **[SEC-44]** `GET /qr/{address}` è pubblico (lo carica un `<img>`, che non
|
||||
può portare il token) ma limitato: rifiuta con 400 `invalid_address` un
|
||||
@@ -450,25 +765,62 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
|
||||
- **[SEC-45]** Un bug report ha una descrizione obbligatoria di 1–2000
|
||||
caratteri (non vuota dopo il trim) e un contatto facoltativo di al più 256
|
||||
caratteri. Può essere inviato anche da non autenticati; se il mittente è
|
||||
autenticato viene associato al suo account, e solo lui vede i propri report
|
||||
(`GET /bug-reports/mine`, autenticato).
|
||||
caratteri. Può essere inviato anche da non autenticati, entro la quota di
|
||||
[SEC-55]; se il mittente è autenticato viene associato al suo account, e
|
||||
solo lui vede i propri report (`GET /bug-reports/mine`, autenticato).
|
||||
*Codice:* `app/api/routes/bug_reports.py:BugReportCreate`,
|
||||
`app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/api/routes/bug_reports.py:list_my_bug_reports` ·
|
||||
*Test:* `tests/unit/test_bug_reports.py`
|
||||
|
||||
- **[SEC-46]** Gli endpoint admin che elencano dati (round, audit log,
|
||||
transazioni pendenti, bug report) accettano `limit` tra 1 e 500. L'elenco
|
||||
utenti non è paginato.
|
||||
transazioni pendenti, bug report, utenti) accettano `limit` tra 1 e 500;
|
||||
l'elenco utenti è paginato con `after_id`
|
||||
([API-33](api.md)).
|
||||
*Codice:* `app/api/routes/admin.py:list_rounds`, `app/api/routes/admin.py:list_audit_log`,
|
||||
`app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`,
|
||||
`app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:*
|
||||
`GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza
|
||||
limite né paginazione.
|
||||
|
||||
Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza
|
||||
SQL composto da stringhe.
|
||||
|
||||
## Ripristino e durabilità
|
||||
|
||||
Un database che torna indietro rispetto alla catena — per un backup
|
||||
ripristinato o per un commit perso — non è solo un problema di dati: può
|
||||
consegnare i fondi di un utente a un altro, o far pagare due volte un round.
|
||||
Le regole sono dei documenti proprietari; qui il loro significato per la
|
||||
sicurezza.
|
||||
|
||||
- **Indirizzi riassegnati.** Un database ripristinato non conosce gli utenti
|
||||
registrati dopo il backup e ne riassegnerebbe gli indici, e quindi
|
||||
indirizzi e fondi, ai nuovi iscritti. All'avvio si controllano i 20 indici
|
||||
successivi al più alto assegnato e, se uno ha cronologia in catena, la
|
||||
registrazione è rifiutata (`registration_unavailable`) e si scrive
|
||||
`restore_gap_detected`
|
||||
([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:*
|
||||
nessun controllo; l'unica difesa è la procedura
|
||||
[OPS-28](operativita.md#ripristino).
|
||||
- **Round rigiocati.** Con `START_PAUSED=true` il server parte con la
|
||||
lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round
|
||||
ha il proprio indirizzo del montepremi, le cui monete sono verificate con
|
||||
il quorum prima del pagamento
|
||||
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-80, B-82): un round
|
||||
del backup già pagato risulta impagabile invece di essere pagato due volte
|
||||
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
|
||||
database e tutti i round condividono lo stesso indirizzo.
|
||||
- **Chiave sbagliata.** Una master key che non corrisponde al database
|
||||
ripristinato ferma l'avvio ([SEC-49]).
|
||||
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
|
||||
è sul disco ([DAT-52](modello-dati.md), B-88): un'interruzione di corrente
|
||||
non può più perdere una registrazione e far riassegnare il suo indice di
|
||||
derivazione. *Oggi:* `synchronous=NORMAL`, con cui in modalità WAL gli
|
||||
ultimi commit possono andare persi.
|
||||
|
||||
## Dati conservati e log
|
||||
|
||||
- **[SEC-47]** Di un utente si conservano solo: username (come digitato), hash
|
||||
@@ -476,40 +828,77 @@ SQL composto da stringhe.
|
||||
`token_version`, data di creazione. **Nessuna email** né altro dato
|
||||
anagrafico. L'unico campo libero che può contenere un recapito è il
|
||||
`contact` facoltativo di un bug report. Gli indirizzi IP dei client non
|
||||
sono salvati nel database: l'app li usa solo nella memoria dei limitatori.
|
||||
sono salvati nel database: l'app li usa nella memoria dei limitatori e
|
||||
compaiono solo nel log di accesso ([SEC-48]).
|
||||
*Codice:* `app/db/models.py:User`, `app/db/models.py:BugReport`
|
||||
|
||||
La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
|
||||
|
||||
- <a id="sec-56"></a>**[SEC-56]** **Il segreto del round è cifrato a riposo.**
|
||||
Fino alla rivelazione il database contiene il segreto dell'estrazione solo
|
||||
cifrato con Fernet, con la stessa chiave della master xprv
|
||||
([modello-dati.md](modello-dati.md) DAT-64). Chi ottiene una copia del
|
||||
database o di un backup, senza la chiave, non può calcolare in anticipo il
|
||||
vincitore di un round aperto, e quindi non può pilotarlo insieme a un
|
||||
minatore; per farlo servono database **e** chiave, cioè lo stesso livello
|
||||
di accesso che già consente di spendere i fondi ([LIM-25](decisioni-e-limiti.md)).
|
||||
Per questo la chiave va conservata separata dai backup del database
|
||||
([operativita.md](operativita.md) OPS-06). All'avvio la verifica di
|
||||
[SEC-49] decifra anche il segreto dei round non ancora rivelati: se non ci
|
||||
riesce, il server non parte.
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-73) · *Oggi:* il segreto non esiste.
|
||||
- **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password
|
||||
(nemmeno quella generata dal reset), né WIF, né token, né la master key. Il
|
||||
log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file) contiene i
|
||||
messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn
|
||||
(indirizzo del peer diretto, metodo, percorso, esito: nessun percorso
|
||||
contiene un segreto, e gli header `Authorization`/`X-Admin-Token` non sono
|
||||
(nemmeno quella generata dal reset), né WIF, né token, né la master key o
|
||||
la sua chiave di cifratura, né `server_seed` prima della rivelazione. Il
|
||||
log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file, e lo stesso
|
||||
contenuto su stdout, quindi in `docker compose logs`) contiene i messaggi
|
||||
dell'app e di uvicorn, incluso il log di accesso di uvicorn (IP del client
|
||||
come lo riporta Caddy, metodo, percorso, esito: nessun percorso contiene un
|
||||
segreto, e gli header `Authorization`/`X-Admin-Token` non sono
|
||||
registrati). L'unico script che stampa un segreto è
|
||||
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
|
||||
generate dal reset compaiono solo nella risposta HTTP all'operatore.
|
||||
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
|
||||
`app/api/routes/admin.py:reset_user_password`
|
||||
*Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il
|
||||
log va solo nel file, e il log di accesso registra l'indirizzo di Caddy,
|
||||
non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)).
|
||||
|
||||
## Rischi accettati
|
||||
|
||||
Limiti noti e voluti, descritti in dettaglio in
|
||||
[decisioni-e-limiti.md](decisioni-e-limiti.md):
|
||||
|
||||
- **Custodia totale.** Chi controlla l'host o `ADMIN_TOKEN` controlla tutti i
|
||||
fondi; la cifratura a riposo non protegge da chi ha accesso all'host.
|
||||
- **Token admin unico e condiviso**, senza identità per operatore né limite
|
||||
di tentativi: una fuga ha il raggio d'azione massimo.
|
||||
- **JWT in `localStorage`**, quindi leggibile da un eventuale XSS; la CSP lo
|
||||
mitiga ma ammette script inline.
|
||||
- **Limiti di frequenza solo su login, registrazione e SSE**, in memoria e per
|
||||
singolo processo.
|
||||
- **Nessuna corroborazione se `ELECTRUM_FALLBACK_SERVERS` è vuoto**: il server
|
||||
Electrum principale diventa fidato (vedi
|
||||
[connessione-electrum.md](connessione-electrum.md#5-il-quorum)).
|
||||
- **Una sola conferma** per ogni transazione, con il rischio di
|
||||
riorganizzazione accettato e nessuna logica di rollback.
|
||||
- **Custodia totale** ([LIM-26](decisioni-e-limiti.md)). Chi controlla l'host
|
||||
o `ADMIN_TOKEN` controlla tutti i fondi; la cifratura a riposo e i permessi
|
||||
stretti non proteggono da chi ha accesso all'host.
|
||||
- **Operatore d'accordo con un minatore** ([LIM-25](decisioni-e-limiti.md)):
|
||||
possono scegliere il vincitore, perché l'operatore conosce il segreto del
|
||||
round prima della rivelazione.
|
||||
- **Attacco al 51% su PLM** ([LIM-24](decisioni-e-limiti.md)): contenuto
|
||||
(6 conferme sui depositi, riorganizzazioni rilevate e pausa, pagamento di
|
||||
ciò che è in catena), non impedito.
|
||||
- **Header a difficoltà minima** ([LIM-14](decisioni-e-limiti.md)): senza il
|
||||
ricalcolo della difficoltà, un server ostile può ancora produrre una breve
|
||||
catena falsa al powLimit; la neutralizzano il quorum e il commit-reveal.
|
||||
- **Server del quorum scelti dall'operatore** ([LIM-11](decisioni-e-limiti.md)):
|
||||
più server della stessa parte contano come voci indipendenti; con
|
||||
`ALLOW_NO_QUORUM=true` (solo sviluppo) il server attivo è fidato.
|
||||
- **Token admin unico e condiviso** ([LIM-06](decisioni-e-limiti.md)), senza
|
||||
identità per operatore né limite di tentativi: una fuga ha il raggio
|
||||
d'azione massimo.
|
||||
- **JWT in `localStorage`** ([LIM-09](decisioni-e-limiti.md)), quindi
|
||||
leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline.
|
||||
- **Limiti di frequenza parziali** ([LIM-07](decisioni-e-limiti.md)): login,
|
||||
registrazione, cambio password, segnalazioni di bug e SSE sì; scommesse,
|
||||
prelievi ed endpoint admin no. Tutti in memoria e per singolo processo.
|
||||
- **`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente**, visibili con
|
||||
`docker inspect` a chi ha accesso a Docker, che è già fidato come l'host.
|
||||
- **Una conferma per le transazioni della piattaforma**
|
||||
([DEC-06](decisioni-e-limiti.md)): scommesse, pagamenti e prelievi le
|
||||
firma solo il server, quindi un utente non può spenderne due volte gli
|
||||
ingressi; solo i depositi ne richiedono 6.
|
||||
- **La master key non è ruotabile**: gli indirizzi degli utenti sono
|
||||
permanenti.
|
||||
Reference in new issue
Block a user