515 lines
29 KiB
Markdown
515 lines
29 KiB
Markdown
# Sicurezza
|
||||
|
|
|
|||
|
|
Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, e
|
|||
|
|
quali rischi accetta consapevolmente. Il documento descrive il codice così
|
|||
|
|
com'è: dove un meccanismo manca, lo dice.
|
|||
|
|
|
|||
|
|
Per la difesa contro server Electrum ostili (validazione degli header,
|
|||
|
|
quorum di corroborazione) vedi [connessione-electrum.md](connessione-electrum.md);
|
|||
|
|
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 [decisioni-e-limiti.md](decisioni-e-limiti.md).
|
|||
|
|
|
|||
|
|
## Modello di fiducia
|
|||
|
|
|
|||
|
|
La piattaforma è **custodiale**: tutte le chiavi private sono generate e
|
|||
|
|
conservate dal server. L'utente non controlla nulla finché non preleva.
|
|||
|
|
|
|||
|
|
| Soggetto | Fiducia | Perché |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Operatore (chi ha `ADMIN_TOKEN`) | **Totale** | Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni. |
|
|||
|
|
| Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra (`.env`). |
|
|||
|
|
| Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. |
|
|||
|
|
| Server Electrum | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum: vedi [connessione-electrum.md](connessione-electrum.md#5-il-quorum). |
|
|||
|
|
| 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`
|
|||
|
|
|
|||
|
|
## Inventario dei segreti
|
|||
|
|
|
|||
|
|
| Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione |
|
|||
|
|
|---|---|---|---|---|---|
|
|||
|
|
| Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza `XPRV_ENCRYPTION_KEY`; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa |
|
|||
|
|
| `XPRV_ENCRYPTION_KEY` | `.env` (variabile d'ambiente del container) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale: decifrare con la chiave vecchia, impostare la nuova, `encrypt_master_key.py --overwrite`. Non esiste uno script dedicato |
|
|||
|
|
| `JWT_SECRET` | `.env` | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni |
|
|||
|
|
| `ADMIN_TOKEN` | `.env`; nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token |
|
|||
|
|
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
|
|||
|
|
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore |
|
|||
|
|
|
|||
|
|
- **[SEC-02]** I segreti (`XPRV_ENCRYPTION_KEY`, `JWT_SECRET`, `ADMIN_TOKEN`)
|
|||
|
|
stanno solo in `.env`, mai nel database o nel repository; `.env`, i file
|
|||
|
|
`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`
|
|||
|
|
|
|||
|
|
- **[SEC-03]** Tutti i parametri operativi stanno nel database
|
|||
|
|
(`round_config`); in `.env` restano solo segreti e collegamenti
|
|||
|
|
infrastrutturali (database, percorso della master key, server Electrum,
|
|||
|
|
documentazione API). Cambiare un valore di `.env` richiede un riavvio.
|
|||
|
|
*Codice:* `app/config.py:Settings`
|
|||
|
|
|
|||
|
|
`.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)"] --> E
|
|||
|
|
E["Cifratura Fernet<br/>con XPRV_ENCRYPTION_KEY"] --> F[("master.xprv.enc<br/>./data/keys/")]
|
|||
|
|
F --> L["Primo uso a runtime:<br/>decifratura e derivazione<br/>di m/84'/746'/0'"]
|
|||
|
|
L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
|
|||
|
|
L --> P["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 `XPRV_ENCRYPTION_KEY` è vuota.
|
|||
|
|
*Codice:* `app/wallet/hd.py:generate_master_key`, `scripts/generate_master_key.py`
|
|||
|
|
|
|||
|
|
- **[SEC-05]** `encrypt_master_key.py` importa una xprv generata altrove: la
|
|||
|
|
legge con input nascosto (`getpass`), verifica che sia una chiave estesa
|
|||
|
|
valida e **privata** (rifiuta una xpub), e la cifra con lo stesso schema.
|
|||
|
|
Non può verificare che sia una chiave PLM: PLM usa gli stessi byte di
|
|||
|
|
versione xprv di Bitcoin. Sovrascrive un file esistente solo con
|
|||
|
|
`--overwrite`.
|
|||
|
|
*Codice:* `scripts/encrypt_master_key.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 `XPRV_ENCRYPTION_KEY`. La xprv
|
|||
|
|
in chiaro non viene mai scritta su disco dal server.
|
|||
|
|
*Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/keystore.py:decrypt_xprv`
|
|||
|
|
|
|||
|
|
La cifratura protegge da una copia del solo file (un backup di `data/`
|
|||
|
|
finito nel posto sbagliato), **non** da chi ha accesso all'host: la chiave di
|
|||
|
|
decifratura sta nel `.env` accanto. Gli script non impostano permessi
|
|||
|
|
restrittivi sul file scritto: valgono quelli predefiniti del sistema.
|
|||
|
|
|
|||
|
|
### 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]** La chiave del montepremi è `m/84'/746'/0'/1/0` (ramo 1
|
|||
|
|
dell'account, indice 0): sta nella stessa master key, non è un segreto
|
|||
|
|
separato. Non esiste un endpoint che ne esporti la chiave privata.
|
|||
|
|
*Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`
|
|||
|
|
|
|||
|
|
### Caricamento a runtime
|
|||
|
|
|
|||
|
|
- **[SEC-09]** La master key viene letta e decifrata **al primo uso** (la
|
|||
|
|
prima derivazione, tipicamente una registrazione o il primo pagamento), non
|
|||
|
|
all'avvio. Da quel momento la chiave dell'account `m/84'/746'/0'` resta in
|
|||
|
|
memoria nel processo per tutta la sua durata; la root xprv non viene
|
|||
|
|
conservata.
|
|||
|
|
*Codice:* `app/wallet/hd.py:_load_account_key`
|
|||
|
|
|
|||
|
|
### 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
|
|||
|
|
|
|||
|
|
- **[SEC-11]** Il container rifiuta di partire se il file della master key non
|
|||
|
|
esiste in `MASTER_KEY_PATH`, prima ancora delle migrazioni.
|
|||
|
|
*Codice:* `Dockerfile` (comando `CMD`)
|
|||
|
|
|
|||
|
|
- **[SEC-12]** All'avvio il server **rifiuta di servire** se `JWT_SECRET` ha
|
|||
|
|
meno di 32 caratteri o se `XPRV_ENCRYPTION_KEY` è vuota; tutti i problemi
|
|||
|
|
sono riportati insieme in un unico `ConfigError`. Il controllo è nel
|
|||
|
|
lifespan, non nella classe `Settings`, perché test e script importano la
|
|||
|
|
configurazione senza avere segreti reali.
|
|||
|
|
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
|||
|
|
*Test:* `tests/unit/test_config.py`
|
|||
|
|
|
|||
|
|
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
|
|||
|
|
risulta semplicemente chiuso (vedi [SEC-24]).
|
|||
|
|
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
|||
|
|
*Test:* `tests/unit/test_config.py`
|
|||
|
|
|
|||
|
|
Il controllo di avvio verifica che `XPRV_ENCRYPTION_KEY` non sia vuota, non
|
|||
|
|
che sia una chiave Fernet valida né che decifri davvero il file: una chiave
|
|||
|
|
malformata o sbagliata emerge solo al primo uso della master key ([SEC-09]).
|
|||
|
|
|
|||
|
|
## Autenticazione degli utenti
|
|||
|
|
|
|||
|
|
Il flusso di registrazione e login è descritto in
|
|||
|
|
[registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione);
|
|||
|
|
qui ci sono le regole di sicurezza.
|
|||
|
|
|
|||
|
|
### 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]** Lunghezza minima della password: 8 caratteri
|
|||
|
|
(`MIN_PASSWORD_LENGTH`), la stessa costante per registrazione e cambio
|
|||
|
|
password. Alla registrazione la password ha anche un massimo di 256
|
|||
|
|
caratteri; il cambio password e il login non impongono un massimo.
|
|||
|
|
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
|
|||
|
|
`app/api/routes/users.py:change_password` ·
|
|||
|
|
*Test:* `tests/unit/test_users.py`
|
|||
|
|
|
|||
|
|
- **[SEC-18]** Lo username è di 3–32 caratteri, solo `A-Z a-z 0-9 _ . -`, e
|
|||
|
|
**non distingue maiuscole e minuscole**: l'unicità è garantita da un indice
|
|||
|
|
unico su `lower(username)` e registrazione e login confrontano
|
|||
|
|
`lower(...)`. Il nome resta salvato come digitato.
|
|||
|
|
*Codice:* `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:register`,
|
|||
|
|
`app/auth/routes.py:login`, `app/db/models.py:User` ·
|
|||
|
|
*Test:* `tests/unit/test_auth.py`
|
|||
|
|
|
|||
|
|
- **[SEC-19]** Il login risponde allo stesso modo (401 `invalid_credentials`)
|
|||
|
|
sia per uno username inesistente sia per una password errata, e i due casi
|
|||
|
|
alimentano lo stesso contatore di tentativi. Nota: se lo username non
|
|||
|
|
esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi
|
|||
|
|
di risposta diversi. La registrazione, invece, rivela per costruzione se uno
|
|||
|
|
username è occupato (409 `username_taken`).
|
|||
|
|
*Codice:* `app/auth/routes.py:login` ·
|
|||
|
|
*Test:* `tests/unit/test_auth.py`
|
|||
|
|
|
|||
|
|
- **[SEC-20]** Non esiste recupero della password né reset self-service
|
|||
|
|
(nessuna email è raccolta). Un utente autenticato può *cambiarla* solo
|
|||
|
|
fornendo quella attuale; altrimenti serve il reset dall'operatore
|
|||
|
|
([SEC-27]).
|
|||
|
|
*Codice:* `app/api/routes/users.py:change_password` ·
|
|||
|
|
*Test:* `tests/unit/test_users.py`
|
|||
|
|
|
|||
|
|
### 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`.
|
|||
|
|
*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`
|
|||
|
|
|
|||
|
|
- **[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 con 403, prima del confronto (che su due
|
|||
|
|
stringhe vuote darebbe esito positivo).
|
|||
|
|
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
|||
|
|
*Test:* `tests/unit/test_admin.py`
|
|||
|
|
|
|||
|
|
- **[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` |
|
|||
|
|
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
|
|||
|
|
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
|
|||
|
|
|
|||
|
|
- **[SEC-30]** Il login è bloccato se **uno qualunque** dei due limitatori
|
|||
|
|
(username o IP) è in blocco, anche con la password giusta; la risposta è
|
|||
|
|
429 `rate_limited` con `retry_after_seconds` nei `params`. Ogni fallimento
|
|||
|
|
alimenta entrambi i limitatori; un successo azzera solo quello dello
|
|||
|
|
username.
|
|||
|
|
*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-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; 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`
|
|||
|
|
|
|||
|
|
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, cambio
|
|||
|
|
password (tentativi sulla password attuale compresi), endpoint admin
|
|||
|
|
(tentativi sul token compresi), invio di bug report (anche anonimi), QR,
|
|||
|
|
`GET /rounds/current` e gli altri endpoint di lettura. Scommesse e prelievi
|
|||
|
|
sono serializzati per utente dal blocco per-utente (vedi
|
|||
|
|
[scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme)),
|
|||
|
|
che non è un limite di frequenza.
|
|||
|
|
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
|
|||
|
|
`app/api/routes/users.py:change_password`, `app/api/routes/bug_reports.py:create_bug_report`
|
|||
|
|
|
|||
|
|
## Indirizzo IP del client
|
|||
|
|
|
|||
|
|
Ogni controllo basato sull'IP (limitatori di login e registrazione, 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`
|
|||
|
|
|
|||
|
|
## TLS e header HTTP
|
|||
|
|
|
|||
|
|
- **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato ottiene e
|
|||
|
|
rinnova un certificato Let's Encrypt; con il valore predefinito
|
|||
|
|
`localhost` usa un certificato autofirmato della propria CA interna.
|
|||
|
|
*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]** Le pagine HTML (`/`, `/admin`, `/guida`, `/report-bug`) sono
|
|||
|
|
servite con `Cache-Control: no-store`. Non è configurato alcun middleware
|
|||
|
|
CORS: il browser consente le chiamate all'API solo dalla stessa origine.
|
|||
|
|
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`
|
|||
|
|
|
|||
|
|
## 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`.
|
|||
|
|
*Codice:* `app/main.py:log_unhandled_exception`
|
|||
|
|
|
|||
|
|
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.
|
|||
|
|
*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`
|
|||
|
|
|
|||
|
|
- **[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; se il mittente è
|
|||
|
|
autenticato viene associato al suo account, e solo lui vede i propri report
|
|||
|
|
(`GET /bug-reports/mine`, autenticato).
|
|||
|
|
*Codice:* `app/api/routes/bug_reports.py:BugReportCreate`,
|
|||
|
|
`app/api/routes/bug_reports.py:create_bug_report`,
|
|||
|
|
`app/api/routes/bug_reports.py:list_my_bug_reports` ·
|
|||
|
|
*Test:* `tests/unit/test_bug_reports.py`
|
|||
|
|
|
|||
|
|
- **[SEC-46]** Gli endpoint admin che elencano dati (round, audit log,
|
|||
|
|
transazioni pendenti, bug report) accettano `limit` tra 1 e 500. L'elenco
|
|||
|
|
utenti non è paginato.
|
|||
|
|
*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`
|
|||
|
|
|
|||
|
|
Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza
|
|||
|
|
SQL composto da stringhe.
|
|||
|
|
|
|||
|
|
## 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 solo nella memoria dei limitatori.
|
|||
|
|
*Codice:* `app/db/models.py:User`, `app/db/models.py:BugReport`
|
|||
|
|
|
|||
|
|
La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
|
|||
|
|
|
|||
|
|
- **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password
|
|||
|
|
(nemmeno quella generata dal reset), né WIF, né token, né la master key. Il
|
|||
|
|
log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file) contiene i
|
|||
|
|
messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn
|
|||
|
|
(indirizzo del peer diretto, metodo, percorso, esito: nessun percorso
|
|||
|
|
contiene un segreto, e gli header `Authorization`/`X-Admin-Token` non sono
|
|||
|
|
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`
|
|||
|
|
|
|||
|
|
## Rischi accettati
|
|||
|
|
|
|||
|
|
Limiti noti e voluti, descritti in dettaglio in
|
|||
|
|
[decisioni-e-limiti.md](decisioni-e-limiti.md):
|
|||
|
|
|
|||
|
|
- **Custodia totale.** Chi controlla l'host o `ADMIN_TOKEN` controlla tutti i
|
|||
|
|
fondi; la cifratura a riposo non protegge da chi ha accesso all'host.
|
|||
|
|
- **Token admin unico e condiviso**, senza identità per operatore né limite
|
|||
|
|
di tentativi: una fuga ha il raggio d'azione massimo.
|
|||
|
|
- **JWT in `localStorage`**, quindi leggibile da un eventuale XSS; la CSP lo
|
|||
|
|
mitiga ma ammette script inline.
|
|||
|
|
- **Limiti di frequenza solo su login, registrazione e SSE**, in memoria e per
|
|||
|
|
singolo processo.
|
|||
|
|
- **Nessuna corroborazione se `ELECTRUM_FALLBACK_SERVERS` è vuoto**: il server
|
|||
|
|
Electrum principale diventa fidato (vedi
|
|||
|
|
[connessione-electrum.md](connessione-electrum.md#5-il-quorum)).
|
|||
|
|
- **Una sola conferma** per ogni transazione, con il rischio di
|
|||
|
|
riorganizzazione accettato e nessuna logica di rollback.
|
|||
|
|
- **La master key non è ruotabile**: gli indirizzi degli utenti sono
|
|||
|
|
permanenti.
|