Replace flowchart/ with a traceable specification in docs/
The two Mermaid diagrams under flowchart/ described the intended flow but had drifted from the code and carried no link back to it. docs/ now holds the specification instead: 15 Italian documents (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history) with 472 numbered requirements, each pointing at the function that implements it and the test that covers it. docs/processo-di-modifica.md defines the ID convention and the spec -> code -> test change workflow, and CLAUDE.md now names docs/ as authoritative. Writing the spec from the code also corrected stale claims in CLAUDE.md (B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control scope, the error contract's bare-string exceptions) and in the operator guides (payouts do retry, only `drawing` fails to resume, rounds do not open without fee_address). The B-70 doc test now pins the prose in docs/scommesse-e-prelievi.md instead of the deleted diagram. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
526a649c8b
commit
61efbb0e37
29 files changed
+6964
-310
No files matched your search
@@ -0,0 +1,515 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user