START_PAUSED=true pauses the lottery as the operator before the scheduler exists. On the first Electrum session the 20 derivation indexes past the highest assigned one are looked up on every server; if any has history, registration stays closed (503 registration_unavailable) and restore_gap_detected is written, so a restored database cannot hand a lost user's address to a new one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
877 lines
53 KiB
Markdown
877 lines
53 KiB
Markdown
# 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)).
|
||
- **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
|
||
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.
|
||
|
||
## 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): 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).
|
||
`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-91), per `XPRV_ENCRYPTION_KEY_FILE`.
|
||
|
||
`.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`, `app/wallet/verify.py` · *Test:* `tests/unit/test_master_key_check.py`
|
||
|
||
### 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` ·
|
||
*Test:* `tests/unit/test_master_key_check.py`
|
||
|
||
### 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/verify.py:verify_master_key`,
|
||
`app/wallet/verify.py:sample_problems` ·
|
||
*Test:* `tests/unit/test_master_key_check.py`
|
||
|
||
## 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)).
|
||
|
||
## 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); la procedura
|
||
[OPS-28](operativita.md#ripristino) toglie la sospensione.
|
||
- **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.
|
||
- **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/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`
|
||
- **[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.
|