Files
plm-lottery/docs/sicurezza.md
T
davideandClaude Sonnet 5.5 fd6a91e93d 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)
All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history
grew forever, other rounds' money and stray coins mixed with a round's own, and
the payout picked from everything it held.

- hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2
  (m/84'/746'/0'/2/<round_id>); no argument is the reserve address 1/0.
  derive_round_pool_key checks the key against the address stored on the round,
  so a payout can never be signed for money its key does not control.
- rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with
  the reserve address since that is where their bets went; add_column, not a
  batch alter, so the expression indexes stay): written in the transaction that
  inserts the round, once its id exists.
- place_bet pays round.pool_address; the payout reads, signs and takes change at
  the round's own address; own_address_for and bump_fee's _signing_context follow
  the round (confirmation poller and reconciler pass round_id).
- Withdrawals to any round pool address, the reserve address or fee_address are
  refused (withdrawal_to_platform_address, 400, all seven languages).
- GET /rounds/current and GET /admin/rounds expose pool_address.
- RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every
  confirmed coin at the round's address, not only the participants' outpoints
  (participants carry no bet_vout yet).
- tests/unit/conftest.py: every test now gets a throwaway master key. Opening a
  round derives an address, and tests that merely opened a round were silently
  reading the real key from the local .env.
- Existing tests changed because the spec changed: payout fixtures give their
  round a pool_address; the reconciler's address-cache test uses two payouts of
  one round (rows of different rounds no longer share an address);
  own_address_for now takes the round id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:52:32 +02:00

900 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sicurezza
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
limiti accettati [limiti-noti.md](limiti-noti.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
([ADR-01](adr/ADR-01-piattaforma-custodiale-tutte-chiavi-generate-tenute.md), [LIM-26](limiti-noti.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; 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 (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. |
- **[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`
### 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](limiti-noti.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](limiti-noti.md)).
Restano **accettati**, e descritti in
[limiti-noti.md](limiti-noti.md):
- **Operatore d'accordo con un minatore** ([LIM-25](limiti-noti.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](limiti-noti.md)): chi può leggere il segreto
dal database può già spendere ogni moneta.
- **Un attacco al 51% su PLM** ([LIM-24](limiti-noti.md)) è
contenuto, non impedito: 6 conferme sui depositi
([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md), B-82), rilevamento delle
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
del round prima del pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.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/`), 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](limiti-noti.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 |
*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, 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
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
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 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)"]
```
### 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
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.
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 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 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
- **[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`
- **[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`, `app/wallet/hd.py:derive_round_pool_key` ·
*Test:* `tests/unit/test_round_pool_address.py`
### Caricamento a runtime
- **[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
- **[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
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]** <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`
- **[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, 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
- **[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`
- **[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/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]~~ 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 [ADR-38](adr/ADR-38-username-unico-spazio-nomi-senza-distinzione.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-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 [ADR-37](adr/ADR-37-nessun-recupero-password-reset.md)); la
parte di sicurezza, cioè il freno sui tentativi, è [SEC-54], e il reset è
[SEC-27].
### 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
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
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` è
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*
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` |
| 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]** <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`
- **[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`
- **[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
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
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,
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 ([LIM-07](limiti-noti.md)).
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
`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, 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
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`
## 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
([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.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](limiti-noti.md)).
*Oggi* (B-84): un `ELECTRUM_FALLBACK_SERVERS` vuoto (il valore di
`.env.example`) è accettato in silenzio e fa passare ogni corroborazione; con
server di riserva la maggioranza è calcolata su chi risponde, quindi una sola
risposta decide; nessun certificato è verificato e non esiste un modo di
fissarlo.
## Host e catena di fornitura
Chi controlla l'host controlla tutti i fondi ([Modello di
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 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:
```
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`.
- **[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
- **[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
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.
## 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
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)).
*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`, `tests/unit/test_round_pool_address.py`
- **[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
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, 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-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 le monete del round non sono ancora verificate con il quorum.
- **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.
## 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
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](limiti-noti.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 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
[limiti-noti.md](limiti-noti.md):
- **Custodia totale** ([LIM-26](limiti-noti.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](limiti-noti.md)):
possono scegliere il vincitore, perché l'operatore conosce il segreto del
round prima della rivelazione.
- **Attacco al 51% su PLM** ([LIM-24](limiti-noti.md)): contenuto
(6 conferme sui depositi, riorganizzazioni rilevate e pausa, pagamento di
ciò che è in catena), non impedito.
- **Header a difficoltà minima** ([LIM-14](limiti-noti.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](limiti-noti.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](limiti-noti.md)), senza
identità per operatore né limite di tentativi: una fuga ha il raggio
d'azione massimo.
- **JWT in `localStorage`** ([LIM-09](limiti-noti.md)), quindi
leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline.
- **Limiti di frequenza parziali** ([LIM-07](limiti-noti.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**
([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.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.