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:
davideandClaude Opus 5.5 committed 2026-09-28 12:16:01 +02:00
1 parent 526a649c8b
commit 61efbb0e37
29 files changed
+6964 -310

No files matched your search

+515
View File
@@ -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.