2026-09-28 12:16:01 +02:00
# Sicurezza
2026-09-29 09:17:52 +02:00
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 )).
2026-09-28 12:16:01 +02:00
Per la difesa contro server Electrum ostili (validazione degli header,
quorum di corroborazione) vedi [connessione-electrum.md ](connessione-electrum.md );
2026-09-29 09:17:52 +02:00
per l'estrazione [ciclo-di-vita-del-round.md ](ciclo-di-vita-del-round.md#3--estrazione );
2026-09-28 12:16:01 +02:00
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
2026-10-14 12:05:57 +02:00
limiti accettati [limiti-noti.md ](limiti-noti.md ).
2026-09-28 12:16:01 +02:00
## Modello di fiducia
La piattaforma è **custodiale** : tutte le chiavi private sono generate e
2026-09-29 09:17:52 +02:00
conservate dal server. L'utente non controlla nulla finché non preleva
2026-10-14 12:05:57 +02:00
([ADR-01 ](adr/ADR-01-piattaforma-custodiale-tutte-chiavi-generate-tenute.md ), [LIM-26 ](limiti-noti.md )).
2026-09-28 12:16:01 +02:00
| 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. |
2026-09-29 09:17:52 +02:00
| 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. |
2026-09-28 12:16:01 +02:00
| Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. |
2026-09-29 09:17:52 +02:00
| 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. |
2026-09-28 12:16:01 +02:00
| 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. |
- **[SEC-01]** Tutte le chiavi private (utenti e montepremi) derivano da
un'unica master xprv generata e custodita dal server; nessuna chiave è mai
generata o conservata lato client.
*Codice:* `app/wallet/hd.py:generate_master_key` , `app/wallet/hd.py:_load_account_key`
2026-09-29 09:17:52 +02:00
### 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
2026-10-14 12:05:57 +02:00
dopo la chiusura ([LIM-13 ](limiti-noti.md )).
2026-09-29 09:17:52 +02:00
- **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 )).
2026-10-14 15:45:34 +02:00
- **Header falsi.** Il bersaglio che un header dichiara nel campo `bits` lo sceglie chi
fabbrica l'header (con `bits = 0x207fffff` un header mai minato passava con
probabilità 1/2): per questo dal B-74 il
2026-09-29 09:17:52 +02:00
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
2026-10-14 12:05:57 +02:00
([LIM-14 ](limiti-noti.md )).
2026-09-29 09:17:52 +02:00
Restano **accettati** , e descritti in
2026-10-14 12:05:57 +02:00
[limiti-noti.md ](limiti-noti.md ):
2026-09-29 09:17:52 +02:00
2026-10-14 12:05:57 +02:00
- **Operatore d'accordo con un minatore** ([LIM-25 ](limiti-noti.md )).
2026-09-29 09:17:52 +02:00
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
2026-10-14 12:05:57 +02:00
tutti i fondi ([LIM-26 ](limiti-noti.md )): chi può leggere il segreto
2026-09-29 09:17:52 +02:00
dal database può già spendere ogni moneta.
2026-10-14 12:05:57 +02:00
- **Un attacco al 51% su PLM** ([LIM-24 ](limiti-noti.md )) è
2026-09-29 09:17:52 +02:00
contenuto, non impedito: 6 conferme sui depositi
2026-10-14 12:05:57 +02:00
([ADR-06 ](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md ), B-82), rilevamento delle
2026-09-29 09:17:52 +02:00
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
2026-10-14 12:05:57 +02:00
del round prima del pagamento ([ADR-52 ](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md ), B-82,
2026-09-29 09:17:52 +02:00
B-85). Il denaro che un attaccante riesce a sottrarre è perso per la
Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
2026-10-16 14:25:35 +02:00
piattaforma.
2026-09-29 09:17:52 +02:00
2026-09-28 12:16:01 +02:00
## Inventario dei segreti
| Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione |
|---|---|---|---|---|---|
2026-09-29 09:17:52 +02:00
| 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 |
2026-10-14 12:05:57 +02:00
| 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 ](limiti-noti.md )) | L'estrazione di quel round non può concludersi | Uno nuovo per ogni round; pubblico dopo la rivelazione |
2026-09-28 12:16:01 +02:00
| 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 |
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
*Oggi* (B-91): la chiave di cifratura è la variabile d'ambiente
2026-09-29 09:17:52 +02:00
`XPRV_ENCRYPTION_KEY` in `.env` , visibile a chiunque esegua `docker inspect`
sul container; `.env` e i file di chiave hanno i permessi predefiniti del
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
sistema (di norma `0644` , leggibili da tutti).
2026-09-29 09:17:52 +02:00
`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
2026-09-28 12:16:01 +02:00
`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`
2026-09-29 09:17:52 +02:00
*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/` .
2026-09-28 12:16:01 +02:00
- **[SEC-03]** Tutti i parametri operativi stanno nel database
2026-09-29 09:17:52 +02:00
(`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 ).
2026-09-28 12:16:01 +02:00
*Codice:* `app/config.py:Settings`
2026-10-14 14:04:35 +02:00
*Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e
`XPRV_ENCRYPTION_KEY_FILE` non esistono.
2026-09-28 12:16:01 +02:00
`.env.example` documenta le chiavi necessarie e il comando per generarle;
non elenca `DATABASE_URL` né `MASTER_KEY_PATH` , che in Docker sono comunque
sovrascritte da `docker-compose.yml` .
## Ciclo di vita della master key
```mermaid
flowchart TD
G["generate_master_key.py<br/>seed casuale 32 byte"] --> E
2026-09-29 09:17:52 +02:00
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)"]
2026-09-28 12:16:01 +02:00
L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
2026-09-29 09:17:52 +02:00
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"]
2026-09-28 12:16:01 +02:00
F --> D["decrypt_master_key.py<br/>stampa la xprv in chiaro<br/>(solo recupero)"]
```
### Generazione
- **[SEC-04]** La master key si crea solo con uno script manuale, mai tramite
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
2026-09-29 09:17:52 +02:00
creazione rifiutano di partire se la chiave di cifratura è vuota. I
controlli sul database e i permessi del file scritto sono in [SEC-50].
2026-09-28 12:16:01 +02:00
*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.
2026-09-29 09:17:52 +02:00
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` .
2026-09-28 12:16:01 +02:00
*Codice:* `scripts/encrypt_master_key.py`
2026-09-29 09:17:52 +02:00
- **[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` ,
2026-10-18 00:38:26 +02:00
`app/wallet/hd.py:generate_master_key` , `app/wallet/verify.py` · *Test:* `tests/unit/test_master_key_check.py`
2026-09-29 09:17:52 +02:00
2026-09-28 12:16:01 +02:00
### Cifratura a riposo
- **[SEC-06]** Il file della master key contiene la xprv in base58 cifrata con
2026-09-29 09:17:52 +02:00
**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.
2026-09-28 12:16:01 +02:00
*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
2026-09-29 09:17:52 +02:00
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` .
2026-09-28 12:16:01 +02:00
### Derivazione
- **[SEC-07]** Gli indirizzi utente seguono BIP84 con coin type 746:
`m/84'/746'/0'/0/<derivation_index>` , un indice per utente, P2WPKH con HRP
`plm` .
*Codice:* `app/wallet/hd.py:derive_user_key` , `app/wallet/hd.py:derive_user_address` ,
`app/wallet/plm_network.py` ·
*Test:* `tests/unit/test_hd.py`
2026-09-29 09:17:52 +02:00
- **[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.
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
*Codice:* `app/wallet/hd.py:derive_pool_key` , `app/wallet/hd.py:derive_pool_address` , `app/wallet/hd.py:derive_round_pool_key` ·
*Test:* `tests/unit/test_round_pool_address.py`
2026-09-28 12:16:01 +02:00
### Caricamento a runtime
2026-09-29 09:17:52 +02:00
- **[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.
2026-10-18 00:38:26 +02:00
*Codice:* `app/wallet/hd.py:_load_account_key` , `app/main.py:lifespan` ·
*Test:* `tests/unit/test_master_key_check.py`
2026-09-28 12:16:01 +02:00
### Recupero
- **[SEC-10]** `decrypt_master_key.py` decifra e stampa la xprv in chiaro, solo
dopo una conferma esplicita (`yes` ) e un avviso sul terminale. Se il
percorso di `MASTER_KEY_PATH` non esiste usa `./data/keys/master.xprv.enc` .
Non esiste un equivalente via API o pannello.
*Codice:* `scripts/decrypt_master_key.py`
## Regole di avvio
2026-09-29 09:17:52 +02:00
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 )).
2026-09-28 12:16:01 +02:00
- **[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` )
2026-09-29 09:17:52 +02:00
- **[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.
2026-09-28 12:16:01 +02:00
*Codice:* `app/config.py:validate_runtime_secrets` , `app/main.py:lifespan` ·
*Test:* `tests/unit/test_config.py`
2026-09-29 09:17:52 +02:00
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
2026-09-28 12:16:01 +02:00
- **[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`
2026-09-29 09:17:52 +02:00
- **[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.
2026-10-18 00:38:26 +02:00
*Codice:* `app/main.py:lifespan` , `app/wallet/verify.py:verify_master_key` ,
`app/wallet/verify.py:sample_problems` ·
*Test:* `tests/unit/test_master_key_check.py`
2026-09-28 12:16:01 +02:00
## Autenticazione degli utenti
2026-09-29 09:17:52 +02:00
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.
2026-09-28 12:16:01 +02:00
### Password
- **[SEC-14]** Le password sono salvate come hash **Argon2id** con i parametri
predefiniti di `argon2-cffi` (`PasswordHasher()` senza argomenti; con la
versione installata 25.1: time cost 3, memoria 64 MiB, parallelismo 4, sale
16 byte, hash 32 byte). Mai in chiaro, mai recuperabili.
*Codice:* `app/auth/security.py:hash_password` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-15]** Ogni hash e verifica chiamati da codice asincrono passano da
`hash_password_async` /`verify_password_async` , che eseguono Argon2 in un
thread del threadpool: un login non deve bloccare l'event loop e con esso le
attività in background. Le versioni sincrone restano per test e script.
*Codice:* `app/auth/security.py:hash_password_async` , `app/auth/security.py:verify_password_async` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-16]** Qualunque errore di verifica (password sbagliata, hash
illeggibile) vale come "password errata", mai come errore del server; un
hash illeggibile viene anche registrato nel log.
*Codice:* `app/auth/security.py:verify_password` ·
*Test:* `tests/unit/test_security.py`
2026-09-29 09:17:52 +02:00
- **[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 ).
2026-09-28 12:16:01 +02:00
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH` , `app/auth/routes.py:RegisterRequest` ,
2026-09-29 09:17:52 +02:00
`app/auth/routes.py:LoginRequest` , `app/api/routes/users.py:ChangePasswordRequest` ·
2026-09-28 12:16:01 +02:00
*Test:* `tests/unit/test_users.py`
2026-09-29 09:17:52 +02:00
*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.
2026-09-28 12:16:01 +02:00
2026-09-29 09:17:52 +02:00
- ~~[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
2026-10-14 12:05:57 +02:00
regole del dominio, con la motivazione in [ADR-38 ](adr/ADR-38-username-unico-spazio-nomi-senza-distinzione.md );
2026-09-29 09:17:52 +02:00
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` ·
2026-09-28 12:16:01 +02:00
*Test:* `tests/unit/test_auth.py`
2026-09-29 09:17:52 +02:00
- ~~[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
2026-10-14 12:05:57 +02:00
il flusso del dominio (motivazione in [ADR-37 ](adr/ADR-37-nessun-recupero-password-reset.md )); la
2026-09-29 09:17:52 +02:00
parte di sicurezza, cioè il freno sui tentativi, è [SEC-54], e il reset è
[SEC-27].
2026-09-28 12:16:01 +02:00
### Token di sessione (JWT)
- **[SEC-21]** Login e registrazione restituiscono un JWT **HS256** firmato con
`JWT_SECRET` , valido `jwt_expire_minutes` (predefinito 1440 = 24 ore). Le
claim sono `sub` (id utente), `tv` (versione del token) ed `exp` . La
decodifica accetta solo l'algoritmo configurato. Il client lo invia come
`Authorization: Bearer …` e lo conserva in `localStorage` .
*Codice:* `app/auth/security.py:create_access_token` , `app/auth/security.py:decode_access_token` ·
*Test:* `tests/unit/test_security.py`
- **[SEC-22]** I token sono **revocabili** : ogni richiesta autenticata
confronta la claim `tv` con `User.token_version` e rifiuta (401
`session_expired` ) un token con una versione diversa, un token non valido o
scaduto, o un utente inesistente. Il cambio password self-service e il reset
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
2026-09-29 09:17:52 +02:00
introduzione) vale come `tv = 0` . Una richiesta senza header
`Authorization` risponde 401 `not_authenticated`
([API-06 ](api.md#convenzioni-generali )).
2026-09-28 12:16:01 +02:00
*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`
2026-09-29 09:17:52 +02:00
*Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:*
l'header assente risponde 401 con il corpo non strutturato di FastAPI
`{"detail": "Not authenticated"}` .
2026-09-28 12:16:01 +02:00
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
`GET /rounds/current` , `POST /bug-reports` ) usano l'utente solo per
personalizzare la risposta: un token assente, non valido o superato li fa
trattare come anonimi, senza errore.
*Codice:* `app/auth/dependencies.py:get_optional_user` ·
*Test:* `tests/unit/test_rounds_route.py` , `tests/unit/test_bug_reports.py`
## Autenticazione dell'operatore
Il pannello `/admin` è una pagina statica pubblica; la protezione sta sugli
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` è
2026-09-29 09:17:52 +02:00
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 )).
2026-09-28 12:16:01 +02:00
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
2026-09-29 09:17:52 +02:00
*Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo
non strutturato `{"detail": "invalid admin token"}` .
2026-09-28 12:16:01 +02:00
- **[SEC-25]** Il token è **unico e condiviso** : non esiste un'identità per
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
l'ha fatto. Non c'è limite di tentativi né lunghezza minima imposta su
`ADMIN_TOKEN` . Nel browser il token è tenuto in `sessionStorage` , quindi si
perde alla chiusura della scheda.
*Codice:* `app/api/routes/admin.py:require_admin` , `app/static/admin.js`
Il token protegge: configurazione e pausa/ripresa della lotteria, elenco
utenti con saldi, export della chiave privata, reset della password, storico
dei round, transazioni pendenti, audit log, bug report. L'elenco completo
degli endpoint è in [api.md ](api.md ).
- **[SEC-26]** `GET /admin/users/{id}/privkey` esporta la chiave privata WIF
dell'utente: è **intenzionale** (il server ha già la master key; l'endpoint
espone solo ciò che l'operatore potrebbe comunque ricavare). Ogni accesso
scrive `admin_privkey_accessed` nell'audit log (con l'id utente, mai la
chiave). Il pannello mostra la WIF con `textContent` , senza conservarla.
*Codice:* `app/api/routes/admin.py:user_privkey` , `app/wallet/hd.py:derive_user_wif` ·
*Test:* `tests/unit/test_admin.py`
- **[SEC-27]** `POST /admin/users/{id}/reset-password` imposta una password
casuale (`secrets.token_urlsafe(12)` , 16 caratteri), la restituisce **una
sola volta** perché l'operatore la comunichi, incrementa `token_version`
([SEC-22]) e scrive `admin_password_reset` nell'audit log (senza la
password).
*Codice:* `app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_admin.py`
- **[SEC-28]** Ogni azione dell'operatore che cambia lo stato lascia una
traccia nell'audit log: modifica della configurazione (`config_updated` ,
con valori prima/dopo, niente se nulla cambia), pausa e ripresa
(`lottery_paused` /`lottery_resumed` , e `paused` non è modificabile da
`PUT /admin/config` ), cambio di stato di un bug report
(`bug_report_status_changed` ). Il catalogo completo è in
[audit-log.md ](audit-log.md ).
*Codice:* `app/api/routes/admin.py:update_config` , `app/api/routes/admin.py:pause_lottery` ,
`app/api/routes/admin.py:resume_lottery` , `app/api/routes/admin.py:update_bug_report_status` ·
*Test:* `tests/unit/test_admin.py` , `tests/unit/test_bug_reports.py`
- **[SEC-29]** `fee_address` , se modificato dall'operatore, deve essere un
indirizzo PLM bech32 valido (checksum e HRP `plm` ): un indirizzo di un'altra
catena manderebbe ogni commissione su uno script irraggiungibile. Anche gli
altri parametri di `PUT /admin/config` hanno limiti minimi e massimi.
*Codice:* `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py`
## Limiti di frequenza
Tutti i limitatori sono **in memoria, nel singolo processo** : si azzerano a
ogni riavvio e non funzionerebbero con più worker.
| Limitatore | Chiave | Regola | Dove |
|---|---|---|---|
| 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` |
2026-09-29 09:17:52 +02:00
| 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` |
2026-09-28 12:16:01 +02:00
| 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` |
2026-09-29 09:17:52 +02:00
- **[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.
2026-09-28 12:16:01 +02:00
*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`
- **[SEC-31]** La registrazione è una **quota** , non un freno sui fallimenti:
5 account per IP ogni 3600s (finestra mobile). Il controllo avviene prima
dell'hash Argon2; un tentativo che non crea l'account (username occupato,
input non valido) non consuma la quota. Oltre la quota: 429 `rate_limited`
con l'attesa esatta fino all'uscita dalla finestra dell'account più vecchio.
*Codice:* `app/auth/routes.py:register` , `app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_auth.py` , `tests/unit/test_rate_limit.py`
2026-09-29 09:17:52 +02:00
- **[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.
2026-09-28 12:16:01 +02:00
- **[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
più recenti.
*Codice:* `app/auth/rate_limit.py:RateLimiter._prune` , `app/auth/rate_limit.py:RollingQuota._prune` ·
*Test:* `tests/unit/test_rate_limit.py`
- **[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
2026-09-29 09:17:52 +02:00
rifiutato) e 500 in totale (oltre, 503 `too_many_streams` ,
[API-16 ](api.md#contratto-degli-errori ); il browser ripiega sul polling).
2026-09-28 12:16:01 +02:00
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe` ,
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
2026-09-29 09:17:52 +02:00
*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"}` .
2026-09-28 12:16:01 +02:00
2026-09-29 09:17:52 +02:00
- **[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
2026-09-28 12:16:01 +02:00
[scommesse-e-prelievi.md ](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme )),
2026-10-14 12:05:57 +02:00
che non è un limite di frequenza ([LIM-07 ](limiti-noti.md )).
2026-09-28 12:16:01 +02:00
*Codice:* `app/api/routes/bets.py` , `app/api/routes/withdrawals.py` ,
2026-09-29 09:17:52 +02:00
`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]).
2026-09-28 12:16:01 +02:00
## Indirizzo IP del client
2026-09-29 09:17:52 +02:00
Ogni controllo basato sull'IP (limitatori di login, registrazione e
segnalazioni di bug, limite SSE per IP) usa la stessa funzione.
2026-09-28 12:16:01 +02:00
- **[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
client può falsificare. Senza l'header si usa l'indirizzo della connessione,
e in mancanza anche di quello `unknown` .
*Codice:* `app/api/client_ip.py:client_ip` ·
*Test:* `tests/unit/test_client_ip.py`
- **[SEC-36]** Caddy **sovrascrive** `X-Forwarded-For` con l'indirizzo reale
del peer (`header_up X-Forwarded-For {remote_host}` ) invece di accodarlo a
quello inviato dal client. Le due difese ([SEC-35] e questa) reggono
ciascuna da sola.
*Codice:* `Caddyfile` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`
- **[SEC-37]** Il container `app` non è pubblicato sull'host: la porta 8123
è solo dichiarata con `expose` sulla rete interna di compose, e l'unico ingresso
dall'esterno è Caddy (porte 80 e 443). È questo che rende affidabile
l'ultimo hop di `X-Forwarded-For` .
*Codice:* `docker-compose.yml`
2026-09-29 09:17:52 +02:00
## 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
2026-10-14 12:05:57 +02:00
([ADR-07 ](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md )).
2026-09-29 09:17:52 +02:00
- **`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
2026-10-14 12:05:57 +02:00
([LIM-11 ](limiti-noti.md )).
2026-09-29 09:17:52 +02:00
## 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 )).
2026-09-28 12:16:01 +02:00
## TLS e header HTTP
2026-09-29 09:17:52 +02:00
- **[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 )).
2026-09-28 12:16:01 +02:00
*Codice:* `Caddyfile` , `docker-compose.yml`
- **[SEC-39]** Caddy aggiunge a ogni risposta questi header:
```
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
` ``
*Codice:* ` Caddyfile` ·
*Test:* ` tests/unit/test_caddyfile_security_headers.py`
La CSP è la principale difesa contro l'XSS, rilevante perché il JWT sta in
` localStorage` e uno script iniettato potrebbe leggerlo. ` script-src` e
` style-src` richiedono però ` 'unsafe-inline'`: entrambe le SPA usano gestori
` onclick` e attributi ` style=""` inline ovunque, e toglierli è un refactoring
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`.
2026-09-29 09:17:52 +02:00
- **[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.
2026-09-28 12:16:01 +02:00
## Superficie esposta
- **[SEC-41]** La documentazione interattiva dell'API (Swagger ` /docs`, ReDoc
` /redoc`, ` /openapi.json`) è **disattivata** salvo ` ENABLE_API_DOCS=true`,
da usare solo in sviluppo: elencherebbe l'intera API, admin compreso.
*Codice:* ` app/config.py:Settings`, ` app/main.py` ·
*Test:* ` tests/unit/test_docs_disabled.py`
- **[SEC-42]** Un errore non gestito risponde 500 con il formato di errore
standard (` internal_error`) e **senza** il testo dell'eccezione, che resta
2026-09-29 09:17:52 +02:00
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.
2026-09-28 12:16:01 +02:00
Il pannello ` /admin` non è collegato da ` /` (né viceversa), ma è una scelta
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
## Validazione degli input
- **[SEC-43]** Un indirizzo fornito dall'esterno (destinazione di un prelievo,
` 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
2026-09-29 09:17:52 +02:00
persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato, e
così quello verso un indirizzo del montepremi, di riserva o delle
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
commissioni ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo)).
2026-09-28 12:16:01 +02:00
*Codice:* ` app/wallet/address.py:is_valid_plm_address`,
` app/withdrawals/service.py:request_withdrawal` ·
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
*Test:* ` tests/unit/test_withdrawals.py`, ` tests/unit/test_qr.py`, ` tests/unit/test_round_pool_address.py`
2026-09-28 12:16:01 +02:00
- **[SEC-44]** ` GET /qr/{address}` è pubblico (lo carica un ` <img>`, che non
può portare il token) ma limitato: rifiuta con 400 ` invalid_address` un
indirizzo più lungo di 100 caratteri o non valido ([SEC-43]) prima di
generare qualunque immagine; il rendering avviene fuori dall'event loop ed è
memorizzato per al più 512 indirizzi.
*Codice:* ` app/api/routes/qr.py:address_qr`, ` app/api/routes/qr.py:_render_png` ·
*Test:* ` tests/unit/test_qr.py`
- **[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
2026-09-29 09:17:52 +02:00
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).
2026-09-28 12:16:01 +02:00
*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,
2026-09-29 09:17:52 +02:00
transazioni pendenti, bug report, utenti) accettano ` limit` tra 1 e 500;
l'elenco utenti è paginato con ` after_id`
([API-33](api.md)).
2026-09-28 12:16:01 +02:00
*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`
2026-09-29 09:17:52 +02:00
*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.
2026-09-28 12:16:01 +02:00
Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza
SQL composto da stringhe.
2026-09-29 09:17:52 +02:00
## 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
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round
2026-09-29 09:17:52 +02:00
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
Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
2026-10-16 14:25:35 +02:00
database.
2026-09-29 09:17:52 +02:00
- **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
2026-09-29 16:26:01 +02:00
derivazione.
2026-09-29 09:17:52 +02:00
2026-09-28 12:16:01 +02:00
## Dati conservati e log
- **[SEC-47]** Di un utente si conservano solo: username (come digitato), hash
Argon2 della password, indice di derivazione, indirizzo, saldo in cache,
` 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
2026-09-29 09:17:52 +02:00
sono salvati nel database: l'app li usa nella memoria dei limitatori e
compaiono solo nel log di accesso ([SEC-48]).
2026-09-28 12:16:01 +02:00
*Codice:* ` app/db/models.py:User`, ` app/db/models.py:BugReport`
La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
2026-09-29 09:17:52 +02:00
- <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
2026-10-14 12:05:57 +02:00
di accesso che già consente di spendere i fondi ([LIM-25](limiti-noti.md)).
2026-09-29 09:17:52 +02:00
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.
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
*Codice:* ` app/rounds/service.py:open_new_round_if_needed`, ` app/wallet/keystore.py:encrypt_round_seed`, ` app/wallet/keystore.py:decrypt_round_seed` ·
*Test:* ` tests/unit/test_rounds_service.py`, ` tests/unit/test_draw_proof.py`
2026-09-28 12:16:01 +02:00
- **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password
2026-09-29 09:17:52 +02:00
(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
2026-09-28 12:16:01 +02:00
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`
2026-09-29 09:17:52 +02:00
*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)).
2026-09-28 12:16:01 +02:00
## Rischi accettati
Limiti noti e voluti, descritti in dettaglio in
2026-10-14 12:05:57 +02:00
[limiti-noti.md](limiti-noti.md):
2026-09-28 12:16:01 +02:00
2026-10-14 12:05:57 +02:00
- **Custodia totale** ([LIM-26](limiti-noti.md)). Chi controlla l'host
2026-09-29 09:17:52 +02:00
o ` ADMIN_TOKEN` controlla tutti i fondi; la cifratura a riposo e i permessi
stretti non proteggono da chi ha accesso all'host.
2026-10-14 12:05:57 +02:00
- **Operatore d'accordo con un minatore** ([LIM-25](limiti-noti.md)):
2026-09-29 09:17:52 +02:00
possono scegliere il vincitore, perché l'operatore conosce il segreto del
round prima della rivelazione.
2026-10-14 12:05:57 +02:00
- **Attacco al 51% su PLM** ([LIM-24](limiti-noti.md)): contenuto
2026-09-29 09:17:52 +02:00
(6 conferme sui depositi, riorganizzazioni rilevate e pausa, pagamento di
ciò che è in catena), non impedito.
2026-10-14 12:05:57 +02:00
- **Header a difficoltà minima** ([LIM-14](limiti-noti.md)): senza il
2026-09-29 09:17:52 +02:00
ricalcolo della difficoltà, un server ostile può ancora produrre una breve
catena falsa al powLimit; la neutralizzano il quorum e il commit-reveal.
2026-10-14 12:05:57 +02:00
- **Server del quorum scelti dall'operatore** ([LIM-11](limiti-noti.md)):
2026-09-29 09:17:52 +02:00
più server della stessa parte contano come voci indipendenti; con
` ALLOW_NO_QUORUM=true` (solo sviluppo) il server attivo è fidato.
2026-10-14 12:05:57 +02:00
- **Token admin unico e condiviso** ([LIM-06](limiti-noti.md)), senza
2026-09-29 09:17:52 +02:00
identità per operatore né limite di tentativi: una fuga ha il raggio
d'azione massimo.
2026-10-14 12:05:57 +02:00
- **JWT in ` localStorage`** ([LIM-09](limiti-noti.md)), quindi
2026-09-29 09:17:52 +02:00
leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline.
2026-10-14 12:05:57 +02:00
- **Limiti di frequenza parziali** ([LIM-07](limiti-noti.md)): login,
2026-09-29 09:17:52 +02:00
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**
2026-10-14 12:05:57 +02:00
([ADR-06 ](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md )): scommesse, pagamenti e prelievi le
2026-09-29 09:17:52 +02:00
firma solo il server, quindi un utente non può spenderne due volte gli
ingressi; solo i depositi ne richiedono 6.
2026-09-28 12:16:01 +02:00
- **La master key non è ruotabile**: gli indirizzi degli utenti sono
permanenti.