# Sicurezza Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, da chi si difende e quali rischi accetta consapevolmente. Il documento descrive il comportamento **atteso** dopo la revisione del 2026-09-28; i requisiti che il codice non soddisfa ancora sono marcati *da implementare*, con una riga *Oggi* che dice cosa fa il codice adesso (vedi [piano-correzioni.md](piano-correzioni.md) e [processo-di-modifica.md](processo-di-modifica.md#requisiti-non-ancora-implementati)). Per la difesa contro server Electrum ostili (validazione degli header, quorum di corroborazione) vedi [connessione-electrum.md](connessione-electrum.md); per l'estrazione [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione); per gli endpoint e i codici di errore [api.md](api.md); per l'elenco degli eventi di audit [audit-log.md](audit-log.md); per backup e procedure operative [operativita.md](operativita.md); per le decisioni di progetto e i limiti accettati [limiti-noti.md](limiti-noti.md). ## Modello di fiducia La piattaforma è **custodiale**: tutte le chiavi private sono generate e conservate dal server. L'utente non controlla nulla finché non preleva ([ADR-01](adr/ADR-01-piattaforma-custodiale-tutte-chiavi-generate-tenute.md), [LIM-26](limiti-noti.md)). | Soggetto | Fiducia | Perché | |---|---|---| | Operatore (chi ha `ADMIN_TOKEN`) | **Totale** | Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni. | | Host del server (chi ha accesso al filesystem o a Docker) | **Totale** | Sullo stesso host stanno la master key cifrata *e* la chiave che la decifra; separarle in file diversi con permessi stretti ([SEC-51], [SEC-53]) protegge da una copia sbagliata dei dati, non da chi controlla l'host. | | Utenti | Nessuna | Ogni input è validato; ogni azione è legata al proprio account tramite JWT. | | Server Electrum (attivo e di riserva) | Nessuna | Header validati e decisioni che muovono denaro corroborate da un quorum di almeno 2 altri server: vedi [Quorum e TLS verso Electrum](#quorum-e-tls-verso-electrum). | | Chi produce i blocchi (minatori, anche con potenza affittata) | Nessuna | Non deve poter scegliere il vincitore né rendere un round impagabile: vedi [Chi produce e trasmette i blocchi](#chi-produce-e-trasmette-i-blocchi). | | Chi trasmette blocchi e transazioni (rete P2P, relay, il server attivo come relay) | Nessuna | Può ritardare, nascondere o riordinare ciò che annuncia; il blocco di estrazione è fissato per altezza e corroborato, e ogni transazione è inviata a tutti i server. | | Rete e client HTTP | Nessuna | TLS terminato da Caddy; l'IP del client è ricavato solo dall'hop scritto da Caddy. | | Caddy (reverse proxy) | Totale | È l'unico proxy davanti all'app, e l'unica fonte di `X-Forwarded-For` di cui l'app si fida. | - **[SEC-01]** Tutte le chiavi private (utenti e montepremi) derivano da un'unica master xprv generata e custodita dal server; nessuna chiave è mai generata o conservata lato client. *Codice:* `app/wallet/hd.py:generate_master_key`, `app/wallet/hd.py:_load_account_key` ### Chi produce e trasmette i blocchi L'hash di un blocco è l'unica fonte di casualità esterna al server, ed è prodotto da chi ha la potenza di calcolo. La potenza di PLM, circa 22 TH/s, si può affittare; scartare un blocco valido costa al minatore solo la ricompensa di quel blocco. - **Perché il solo hash del blocco era manipolabile.** Se il seme è il solo hash del blocco, chi mina con biglietti in gioco conosce l'esito di ogni blocco che trova *prima* di pubblicarlo, e lo scarta finché uno non lo fa vincere. Chi trasmette i blocchi ha una leva analoga: il server Electrum attivo può non annunciare un blocco e far estrarre dal successivo; la corroborazione verificava che il blocco esistesse, non che fosse il primo dopo la chiusura ([LIM-13](limiti-noti.md)). - **Come il commit-reveal toglie il movente al minatore.** All'apertura il server si impegna su un segreto pubblicandone solo l'impronta ([RND-34](ciclo-di-vita-del-round.md#0--apertura)); all'ingresso in `drawing` si fissano l'elenco dei partecipanti e l'altezza del blocco ([RND-33](ciclo-di-vita-del-round.md#2--chiusura)); il seme combina segreto, hash del blocco a quell'altezza ed elenco, e dopo il salvataggio del vincitore il segreto è rivelato con la prova pubblica ([RND-37](ciclo-di-vita-del-round.md#3--estrazione)). Un minatore che non conosce il segreto non sa chi vincerebbe con il suo blocco, quindi non ha motivo di scartarlo; un server che trasmette non può scegliere il blocco, perché l'altezza è fissata e l'header a quell'altezza deve essere confermato dal quorum ([ELC-15](connessione-electrum.md#5-il-quorum)). - **Header falsi.** Il bersaglio che un header dichiara nel campo `bits` lo sceglie chi fabbrica l'header (con `bits = 0x207fffff` un header mai minato passava con probabilità 1/2): per questo dal B-74 il bersaglio non può essere più facile del powLimit di PLM, `0x1d00ffff` ([ELC-11](connessione-electrum.md#4-header-validati-non-creduti)), e ogni salto di più blocchi è verificato come catena. Anche al powLimit un header costa in media 2³² hash, pochi millisecondi a un ASIC: il controllo toglie la contraffazione gratuita, non quella economica. A proteggere l'estrazione sono il quorum sull'header di estrazione e il segreto del commit-reveal ([LIM-14](limiti-noti.md)). Restano **accettati**, e descritti in [limiti-noti.md](limiti-noti.md): - **Operatore d'accordo con un minatore** ([LIM-25](limiti-noti.md)). L'operatore conosce il segreto prima della rivelazione e può calcolare l'esito di ogni blocco candidato; un minatore complice può scartare quelli sfavorevoli. È accettato perché l'operatore è già fidato come custode di tutti i fondi ([LIM-26](limiti-noti.md)): chi può leggere il segreto dal database può già spendere ogni moneta. - **Un attacco al 51% su PLM** ([LIM-24](limiti-noti.md)) è contenuto, non impedito: 6 conferme sui depositi ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md), B-82), rilevamento delle riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete del round prima del pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82, B-85). Il denaro che un attaccante riesce a sottrarre è perso per la piattaforma. ## Inventario dei segreti | Segreto | Dove vive | Come si genera | Se trapela | Se si perde | Rotazione | |---|---|---|---|---|---| | Master xprv | File cifrato `MASTER_KEY_PATH` (in Docker `./data/keys/master.xprv.enc`, montato in `/app/key_data/`), permessi `0600` ([SEC-51]) | `scripts/generate_master_key.py` (32 byte da `os.urandom`) oppure importata con `scripts/encrypt_master_key.py` | Il file da solo è inutile senza la chiave di cifratura; insieme, chi li ha può spendere **tutti** i fondi (utenti e montepremi) | Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con `decrypt_master_key.py` | **Non ruotabile**: gli indirizzi degli utenti sono permanenti e derivati da essa | | Chiave di cifratura della master key (Fernet) | File `./secrets/xprv_encryption.key` (permessi `0600`, fuori da `./data/`), montato in sola lettura come secret di compose in `/run/secrets/xprv_encryption_key` e indicato da `XPRV_ENCRYPTION_KEY_FILE` ([SEC-53]) | `Fernet.generate_key()` (comando in `.env.example`) | Insieme al file cifrato: come sopra | Il file cifrato non è più leggibile: fondi persi senza backup della xprv | Manuale, con la procedura [OPS-22](operativita.md#segreto-compromesso). Non esiste uno script dedicato | | `JWT_SECRET` | `.env` (permessi `0600`) | `secrets.token_urlsafe(32)` | Chiunque può firmare un token per qualunque utente (con il `tv` corretto, che è un piccolo intero) e agire come lui, prelievi compresi | Nessuna perdita di dati: se ne genera uno nuovo | Cambiarlo e riavviare invalida **tutte** le sessioni | | `ADMIN_TOKEN` | `.env` (permessi `0600`); nel browser dell'operatore in `sessionStorage` (`plm_admin_token`) | `secrets.token_urlsafe(32)` | Accesso completo a `/admin/*`, compreso l'export delle chiavi private | Nessuna: se ne imposta uno nuovo | Cambiarlo e riavviare; l'operatore reinserisce il token | | Segreto del round (`server_seed`) | Tabella `rounds`, **cifrato** con la chiave della master xprv (`server_seed_enc`) fino alla rivelazione, poi in chiaro ([RND-34](ciclo-di-vita-del-round.md#0--apertura), [SEC-56]) | 32 byte da un generatore crittograficamente sicuro, all'apertura del round | Prima della rivelazione permette di calcolare il vincitore di ogni blocco candidato ([LIM-25](limiti-noti.md)) | L'estrazione di quel round non può concludersi | Uno nuovo per ogni round; pubblico dopo la rivelazione | | Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore | | JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore | *Oggi* (B-91): la chiave di cifratura è la variabile d'ambiente `XPRV_ENCRYPTION_KEY` in `.env`, visibile a chiunque esegua `docker inspect` sul container; `.env` e i file di chiave hanno i permessi predefiniti del sistema (di norma `0644`, leggibili da tutti). `JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente anche dopo B-91: le vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come l'host. - **[SEC-02]** I segreti stanno fuori dal database e dal repository: `JWT_SECRET` e `ADMIN_TOKEN` in `.env`, la chiave di cifratura della master key nel proprio file ([SEC-53]). `.env`, la cartella `secrets/`, i file `master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da git sia dall'immagine Docker. *Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore` *Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di `secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env` (`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono `secrets/`. - **[SEC-03]** Tutti i parametri operativi stanno nel database (`round_config`); in `.env` restano solo segreti, collegamenti infrastrutturali (database, percorso della master key, file della chiave di cifratura, server Electrum) e flag di avvio (`ENABLE_API_DOCS`, `ALLOW_NO_QUORUM`, `START_PAUSED`). Cambiare un valore di `.env` richiede un riavvio. L'elenco completo è in [architettura.md](architettura.md#configurazione). *Codice:* `app/config.py:Settings` *Stato:* da implementare (B-90, B-91) · *Oggi:* `START_PAUSED` e `XPRV_ENCRYPTION_KEY_FILE` non esistono. `.env.example` documenta le chiavi necessarie e il comando per generarle; non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque sovrascritte da `docker-compose.yml`. ## Ciclo di vita della master key ```mermaid flowchart TD G["generate_master_key.py
seed casuale 32 byte"] --> E I["encrypt_master_key.py
xprv importata (input nascosto)
confrontata con il database (SEC-50)"] --> E E["Cifratura Fernet
con la chiave di XPRV_ENCRYPTION_KEY_FILE"] --> F[("master.xprv.enc
./data/keys/, 0600")] F --> L["Avvio del server:
decifratura, derivazione di m/84'/746'/0'
e confronto con il database (SEC-49)"] L --> U["Utenti
m/84'/746'/0'/0/indice"] L --> P["Montepremi del round
m/84'/746'/0'/2/round_id"] L --> R["Riserva del montepremi
m/84'/746'/0'/1/0"] F --> D["decrypt_master_key.py
stampa la xprv in chiaro
(solo recupero)"] ``` ### Generazione - **[SEC-04]** La master key si crea solo con uno script manuale, mai tramite API: `generate_master_key.py` genera un seed casuale di 32 byte, ne ricava la xprv (versione PLM mainnet), la cifra e la scrive in `MASTER_KEY_PATH`. Rifiuta di sovrascrivere un file esistente; entrambi gli script di creazione rifiutano di partire se la chiave di cifratura è vuota. I controlli sul database e i permessi del file scritto sono in [SEC-50]. *Codice:* `app/wallet/hd.py:generate_master_key`, `scripts/generate_master_key.py` - **[SEC-05]** `encrypt_master_key.py` importa una xprv generata altrove: la legge con input nascosto (`getpass`), verifica che sia una chiave estesa valida e **privata** (rifiuta una xpub), e la cifra con lo stesso schema. I byte di versione non bastano a riconoscere una chiave PLM (PLM usa gli stessi byte di versione xprv di Bitcoin): a riconoscere la chiave giusta è il confronto con gli indirizzi già salvati ([SEC-50]). Sovrascrive un file esistente solo con `--overwrite`. *Codice:* `scripts/encrypt_master_key.py` - **[SEC-50]** Gli script che scrivono la master key controllano il database indicato da `DATABASE_URL` prima di scrivere: `generate_master_key.py` rifiuta se il database contiene già utenti o round, perché una chiave nuova non ne riprodurrebbe gli indirizzi; `encrypt_master_key.py` ricalcola dalla xprv ricevuta gli indirizzi del campione di [SEC-49] e rifiuta, anche con `--overwrite`, se uno solo differisce da quello salvato. Un database assente o vuoto non ha nulla da confrontare e non blocca. Entrambi gli script creano il file con permessi `0600` (senza passare da permessi più larghi) e lo riscrivono con gli stessi permessi. *Codice:* `scripts/generate_master_key.py`, `scripts/encrypt_master_key.py`, `app/wallet/hd.py:generate_master_key` · *Test:* — *Stato:* da implementare (B-89) · *Oggi:* gli script accettano qualunque xprv senza guardare il database e scrivono il file con i permessi predefiniti del sistema (di norma `0644`). ### Cifratura a riposo - **[SEC-06]** Il file della master key contiene la xprv in base58 cifrata con **Fernet** (AES-128-CBC + HMAC-SHA256) usando la chiave di cifratura ([SEC-53]). La xprv in chiaro non viene mai scritta su disco dal server. *Codice:* `app/wallet/keystore.py:encrypt_xprv`, `app/wallet/keystore.py:decrypt_xprv` La cifratura protegge da una copia del solo file (un backup di `data/` finito nel posto sbagliato), **non** da chi ha accesso all'host: la chiave di decifratura sta sullo stesso host. Tenerla fuori da `./data/` ([SEC-53]) fa sì che un backup dei dati non contenga entrambe. - **[SEC-53]** La chiave di cifratura della master key è un **file**, `./secrets/xprv_encryption.key` sull'host (cartella `0700`, file `0600`, fuori da `./data/`), montato in sola lettura come secret di compose in `/run/secrets/xprv_encryption_key`; l'app legge il percorso da `XPRV_ENCRYPTION_KEY_FILE` ([architettura.md](architettura.md#configurazione), ARC-05, ARC-30). La chiave non compare in alcuna variabile d'ambiente del container, quindi `docker inspect` non la mostra, e non finisce in un backup di `./data/`. *Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`, `docker-compose.yml` · *Test:* — *Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile `XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e visibile con `docker inspect`. ### Derivazione - **[SEC-07]** Gli indirizzi utente seguono BIP84 con coin type 746: `m/84'/746'/0'/0/`, un indice per utente, P2WPKH con HRP `plm`. *Codice:* `app/wallet/hd.py:derive_user_key`, `app/wallet/hd.py:derive_user_address`, `app/wallet/plm_network.py` · *Test:* `tests/unit/test_hd.py` - **[SEC-08]** Le chiavi del montepremi — una per round, `m/84'/746'/0'/2/`, e quella di riserva, `m/84'/746'/0'/1/0` ([REG-14](registrazione-e-depositi.md#il-conto-del-montepremi)) — stanno nella stessa master key, non sono segreti separati. Non esiste un endpoint che ne esporti la chiave privata. *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`, `app/wallet/hd.py:derive_round_pool_key` · *Test:* `tests/unit/test_round_pool_address.py` ### Caricamento a runtime - **[SEC-09]** La master key viene letta e decifrata **all'avvio**, nel lifespan, prima di creare le attività in background, ed è subito verificata ([SEC-49]). Da quel momento la chiave dell'account `m/84'/746'/0'` resta in memoria nel processo per tutta la sua durata; la root xprv non viene conservata. *Codice:* `app/wallet/hd.py:_load_account_key`, `app/main.py:lifespan` *Stato:* da implementare (B-89) · *Oggi:* la chiave è decifrata al primo uso (la prima derivazione, tipicamente una registrazione o il primo pagamento), non all'avvio. ### Recupero - **[SEC-10]** `decrypt_master_key.py` decifra e stampa la xprv in chiaro, solo dopo una conferma esplicita (`yes`) e un avviso sul terminale. Se il percorso di `MASTER_KEY_PATH` non esiste usa `./data/keys/master.xprv.enc`. Non esiste un equivalente via API o pannello. *Codice:* `scripts/decrypt_master_key.py` ## Regole di avvio L'ordine completo dei controlli di avvio è in [architettura.md](architettura.md#nel-lifespan) (ARC-19, ARC-60); qui ci sono quelli di sicurezza. Il rifiuto di partire con meno di 2 server Electrum di riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi [Quorum e TLS verso Electrum](#quorum-e-tls-verso-electrum)). - **[SEC-11]** Il container rifiuta di partire se il file della master key non esiste in `MASTER_KEY_PATH`, prima ancora delle migrazioni. *Codice:* `Dockerfile` (comando `CMD`) - **[SEC-12]** All'avvio il server **rifiuta di servire** se `JWT_SECRET` ha meno di 32 caratteri o se la chiave di cifratura della master key è vuota (il file indicato da `XPRV_ENCRYPTION_KEY_FILE` manca o è vuoto); tutti i problemi sono riportati insieme in un unico `ConfigError`. Il controllo è nel lifespan, non nella classe `Settings`, perché test e script importano la configurazione senza avere segreti reali. *Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Test:* `tests/unit/test_config.py` *Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota. - **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello risulta semplicemente chiuso (vedi [SEC-24]). *Codice:* `app/config.py:validate_runtime_secrets` · *Test:* `tests/unit/test_config.py` - **[SEC-49]** Dopo [SEC-12], il server decifra la master key ([SEC-09]) e ne ricava gli indirizzi di un campione fisso, confrontandoli con quelli salvati nel database: - l'indirizzo di riserva `m/84'/746'/0'/1/0`, con il `pool_address` di ogni round che lo usa; - gli indirizzi degli utenti con i 10 `derivation_index` più bassi e i 10 più alti (tutti, se gli utenti sono meno di 20), con `users.address`; - gli indirizzi `m/84'/746'/0'/2/` degli ultimi 10 round per `id` che hanno un indirizzo proprio, con il loro `pool_address`; - il `server_seed_enc` di ogni round non ancora rivelato, che deve decifrarsi e avere `sha256` uguale al suo `server_seed_hash` ([SEC-56]). Se la chiave non si decifra (chiave di cifratura sbagliata o malformata, file corrotto) o **un solo** indirizzo differisce, il server **rifiuta di servire**: il lifespan scrive nel log quale controllo è fallito (tipo e indice o id, mai la chiave) e il processo termina. Un database senza utenti né round non ha nulla da confrontare: basta che la chiave si decifri. Lo scopo è non partire con una master key diversa da quella che ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il montepremi e farebbe firmare transazioni che la rete rifiuta. *Codice:* `app/main.py:lifespan`, `app/wallet/hd.py:_load_account_key`, `app/wallet/hd.py:derive_user_address`, `app/wallet/hd.py:derive_pool_address` · *Test:* — *Stato:* da implementare (B-89) · *Oggi:* all'avvio non si decifra nulla; una chiave di cifratura sbagliata o malformata emerge solo al primo uso della master key (prima registrazione o primo pagamento), e una xprv diversa da quella originale non emerge affatto: produce indirizzi nuovi, compreso quello del montepremi. ## Autenticazione degli utenti Il flusso di registrazione, login e cambio password è descritto in [registrazione-e-depositi.md](registrazione-e-depositi.md#registrazione), che ne è il proprietario; qui ci sono le regole di sicurezza. ### Password - **[SEC-14]** Le password sono salvate come hash **Argon2id** con i parametri predefiniti di `argon2-cffi` (`PasswordHasher()` senza argomenti; con la versione installata 25.1: time cost 3, memoria 64 MiB, parallelismo 4, sale 16 byte, hash 32 byte). Mai in chiaro, mai recuperabili. *Codice:* `app/auth/security.py:hash_password` · *Test:* `tests/unit/test_security.py` - **[SEC-15]** Ogni hash e verifica chiamati da codice asincrono passano da `hash_password_async`/`verify_password_async`, che eseguono Argon2 in un thread del threadpool: un login non deve bloccare l'event loop e con esso le attività in background. Le versioni sincrone restano per test e script. *Codice:* `app/auth/security.py:hash_password_async`, `app/auth/security.py:verify_password_async` · *Test:* `tests/unit/test_security.py` - **[SEC-16]** Qualunque errore di verifica (password sbagliata, hash illeggibile) vale come "password errata", mai come errore del server; un hash illeggibile viene anche registrato nel log. *Codice:* `app/auth/security.py:verify_password` · *Test:* `tests/unit/test_security.py` - **[SEC-17]** Soglie delle credenziali: una password nuova ha almeno 8 caratteri (`MIN_PASSWORD_LENGTH`, la stessa costante per registrazione e cambio password), e **ogni** campo di credenziali — username e password alla registrazione e al login, password attuale e nuova al cambio password — ha al massimo 256 caratteri. Il massimo limita il lavoro di Argon2 e la memoria che una sola richiesta può chiedere. Come ciascun endpoint le applica, e con quale errore, è in [REG-01](registrazione-e-depositi.md#registrazione), [REG-09](registrazione-e-depositi.md#sessione-e-password) e [REG-12](registrazione-e-depositi.md#sessione-e-password). *Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`, `app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` · *Test:* `tests/unit/test_users.py` *Stato:* da implementare (B-94), per il massimo su login e cambio password · *Oggi:* il massimo di 256 vale solo per la password della registrazione; i campi del login e del cambio password non hanno limite di lunghezza. - ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione) e [REG-02](registrazione-e-depositi.md#registrazione): formato dello username, confronto senza maiuscole e indice unico su `lower(username)` sono regole del dominio, con la motivazione in [ADR-38](adr/ADR-38-username-unico-spazio-nomi-senza-distinzione.md); qui non resta nulla di specifico della sicurezza. - **[SEC-19]** Login con username inesistente e con password errata danno la stessa risposta ([REG-09](registrazione-e-depositi.md#sessione-e-password)) e alimentano lo stesso contatore di tentativi ([SEC-30]). Due canali rivelano comunque se uno username esiste, e sono accettati: se lo username non esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi di risposta diversi; la registrazione risponde per costruzione `username_taken` (409) a uno username occupato. *Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:register` · *Test:* `tests/unit/test_auth.py` - ~~[SEC-20]~~ Assorbito da [REG-12](registrazione-e-depositi.md#sessione-e-password) e [REG-13](registrazione-e-depositi.md#sessione-e-password): l'assenza di recupero, il cambio con la password attuale e il reset dall'operatore sono il flusso del dominio (motivazione in [ADR-37](adr/ADR-37-nessun-recupero-password-reset.md)); la parte di sicurezza, cioè il freno sui tentativi, è [SEC-54], e il reset è [SEC-27]. ### Token di sessione (JWT) - **[SEC-21]** Login e registrazione restituiscono un JWT **HS256** firmato con `JWT_SECRET`, valido `jwt_expire_minutes` (predefinito 1440 = 24 ore). Le claim sono `sub` (id utente), `tv` (versione del token) ed `exp`. La decodifica accetta solo l'algoritmo configurato. Il client lo invia come `Authorization: Bearer …` e lo conserva in `localStorage`. *Codice:* `app/auth/security.py:create_access_token`, `app/auth/security.py:decode_access_token` · *Test:* `tests/unit/test_security.py` - **[SEC-22]** I token sono **revocabili**: ogni richiesta autenticata confronta la claim `tv` con `User.token_version` e rifiuta (401 `session_expired`) un token con una versione diversa, un token non valido o scaduto, o un utente inesistente. Il cambio password self-service e il reset dell'operatore incrementano `token_version`, invalidando ogni sessione precedente; il cambio password restituisce un token nuovo, così la sessione corrente resta attiva. Un token privo di `tv` (emesso prima della sua introduzione) vale come `tv = 0`. Una richiesta senza header `Authorization` risponde 401 `not_authenticated` ([API-06](api.md#convenzioni-generali)). *Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`, `app/api/routes/admin.py:reset_user_password` · *Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py` *Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:* l'header assente risponde 401 con il corpo non strutturato di FastAPI `{"detail": "Not authenticated"}`. - **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio `GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per personalizzare la risposta: un token assente, non valido o superato li fa trattare come anonimi, senza errore. *Codice:* `app/auth/dependencies.py:get_optional_user` · *Test:* `tests/unit/test_rounds_route.py`, `tests/unit/test_bug_reports.py` ## Autenticazione dell'operatore Il pannello `/admin` è una pagina statica pubblica; la protezione sta sugli endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`. - **[SEC-24]** Ogni endpoint `/admin/*` confronta `X-Admin-Token` con `ADMIN_TOKEN` in tempo costante (`secrets.compare_digest` sui byte UTF-8, così un token con caratteri non ASCII dà 403 e non 500). Se `ADMIN_TOKEN` è vuoto **ogni** richiesta è rifiutata, prima del confronto (che su due stringhe vuote darebbe esito positivo). Il rifiuto è 403 con codice `invalid_admin_token` ([API-08](api.md#convenzioni-generali)). *Codice:* `app/api/routes/admin.py:require_admin` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo non strutturato `{"detail": "invalid admin token"}`. - **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi* l'ha fatto. Non c'è limite di tentativi né lunghezza minima imposta su `ADMIN_TOKEN`. Nel browser il token è tenuto in `sessionStorage`, quindi si perde alla chiusura della scheda. *Codice:* `app/api/routes/admin.py:require_admin`, `app/static/admin.js` Il token protegge: configurazione e pausa/ripresa della lotteria, elenco utenti con saldi, export della chiave privata, reset della password, storico dei round, transazioni pendenti, audit log, bug report. L'elenco completo degli endpoint è in [api.md](api.md). - **[SEC-26]** `GET /admin/users/{id}/privkey` esporta la chiave privata WIF dell'utente: è **intenzionale** (il server ha già la master key; l'endpoint espone solo ciò che l'operatore potrebbe comunque ricavare). Ogni accesso scrive `admin_privkey_accessed` nell'audit log (con l'id utente, mai la chiave). Il pannello mostra la WIF con `textContent`, senza conservarla. *Codice:* `app/api/routes/admin.py:user_privkey`, `app/wallet/hd.py:derive_user_wif` · *Test:* `tests/unit/test_admin.py` - **[SEC-27]** `POST /admin/users/{id}/reset-password` imposta una password casuale (`secrets.token_urlsafe(12)`, 16 caratteri), la restituisce **una sola volta** perché l'operatore la comunichi, incrementa `token_version` ([SEC-22]) e scrive `admin_password_reset` nell'audit log (senza la password). *Codice:* `app/api/routes/admin.py:reset_user_password` · *Test:* `tests/unit/test_admin.py` - **[SEC-28]** Ogni azione dell'operatore che cambia lo stato lascia una traccia nell'audit log: modifica della configurazione (`config_updated`, con valori prima/dopo, niente se nulla cambia), pausa e ripresa (`lottery_paused`/`lottery_resumed`, e `paused` non è modificabile da `PUT /admin/config`), cambio di stato di un bug report (`bug_report_status_changed`). Il catalogo completo è in [audit-log.md](audit-log.md). *Codice:* `app/api/routes/admin.py:update_config`, `app/api/routes/admin.py:pause_lottery`, `app/api/routes/admin.py:resume_lottery`, `app/api/routes/admin.py:update_bug_report_status` · *Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py` - **[SEC-29]** `fee_address`, se modificato dall'operatore, deve essere un indirizzo PLM bech32 valido (checksum e HRP `plm`): un indirizzo di un'altra catena manderebbe ogni commissione su uno script irraggiungibile. Anche gli altri parametri di `PUT /admin/config` hanno limiti minimi e massimi. *Codice:* `app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py` ## Limiti di frequenza Tutti i limitatori sono **in memoria, nel singolo processo**: si azzerano a ogni riavvio e non funzionerebbero con più worker. | Limitatore | Chiave | Regola | Dove | |---|---|---|---| | Login per username | `user:` | 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:` | 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:` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` | | Cambio password per utente *(B-94, da implementare)* | `user:`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` | | Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` | | Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` | | Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` | - **[SEC-30]** Il login è bloccato se **uno qualunque** dei due limitatori (username o IP) è in blocco, anche con la password giusta; la risposta è 429 `rate_limited` con `retry_after_seconds` nei `params`. Ogni fallimento alimenta entrambi i limitatori; un successo azzera solo quello dello username. *Codice:* `app/auth/routes.py:login`, `app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` · *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` - **[SEC-31]** La registrazione è una **quota**, non un freno sui fallimenti: 5 account per IP ogni 3600s (finestra mobile). Il controllo avviene prima dell'hash Argon2; un tentativo che non crea l'account (username occupato, input non valido) non consuma la quota. Oltre la quota: 429 `rate_limited` con l'attesa esatta fino all'uscita dalla finestra dell'account più vecchio. *Codice:* `app/auth/routes.py:register`, `app/auth/rate_limit.py:RollingQuota` · *Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py` - **[SEC-54]** Il cambio password ha un freno sui fallimenti per utente, con le soglie della tabella: ogni `current_password` sbagliata registra un fallimento sulla chiave `user:` di un `RateLimiter` dedicato (soglia 5, attesa iniziale 2s, massima 300s, lo stesso modello del login per username), un cambio riuscito lo azzera. Il blocco è verificato **prima** dell'hash Argon2 e anche con la password giusta; la risposta è 429 `rate_limited` con `retry_after_seconds` nei `params`. Serve a chi ha rubato un token valido e vuole scoprire la password, per poi cambiarla e chiudere fuori il proprietario. L'evento `password_changed` e l'ordine degli errori sono di [REG-12](registrazione-e-depositi.md#sessione-e-password) e [API-21](api.md). *Codice:* `app/api/routes/users.py:change_password`, `app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` · *Test:* — *Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido la password attuale si può provare senza limiti. - **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP ([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile, un `RollingQuota` dedicato), anche per un chiamante autenticato. Il controllo avviene prima dell'inserimento; un invio rifiutato per validazione non consuma la quota. Oltre la quota: 429 `rate_limited` con l'attesa esatta fino all'uscita dalla finestra della segnalazione più vecchia ([API-28](api.md)). *Codice:* `app/api/routes/bug_reports.py:create_bug_report`, `app/auth/rate_limit.py:RollingQuota` · *Test:* — *Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può inserire segnalazioni senza limite, fino a riempire il database. - **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000 chiavi; oltre, si scartano le voci più vicine alla scadenza, mai i blocchi più recenti. *Codice:* `app/auth/rate_limit.py:RateLimiter._prune`, `app/auth/rate_limit.py:RollingQuota._prune` · *Test:* `tests/unit/test_rate_limit.py` - **[SEC-33]** Il canale SSE accetta al massimo 5 stream contemporanei per IP (il sesto chiude il più vecchio dello stesso IP invece di essere rifiutato) e 500 in totale (oltre, 503 `too_many_streams`, [API-16](api.md#contratto-degli-errori); il browser ripiega sul polling). *Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`, `app/api/routes/rounds.py:round_stream` · *Test:* `tests/unit/test_round_events.py` *Stato:* da implementare (B-94), per il codice `too_many_streams` · *Oggi:* il 503 ha il corpo non strutturato `{"detail": "too many concurrent update streams"}`. - **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi, endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e gli altri endpoint di lettura. Scommesse e prelievi sono serializzati per utente dal blocco per-utente (vedi [scommesse-e-prelievi.md](scommesse-e-prelievi.md#scommessa-e-prelievo-insieme)), che non è un limite di frequenza ([LIM-07](limiti-noti.md)). *Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`, `app/api/routes/admin.py:require_admin` *Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]). ## Indirizzo IP del client Ogni controllo basato sull'IP (limitatori di login, registrazione e segnalazioni di bug, limite SSE per IP) usa la stessa funzione. - **[SEC-35]** L'IP del client è l'**ultimo** elemento di `X-Forwarded-For` (quello scritto dall'hop più vicino, cioè Caddy), non il primo, che il client può falsificare. Senza l'header si usa l'indirizzo della connessione, e in mancanza anche di quello `unknown`. *Codice:* `app/api/client_ip.py:client_ip` · *Test:* `tests/unit/test_client_ip.py` - **[SEC-36]** Caddy **sovrascrive** `X-Forwarded-For` con l'indirizzo reale del peer (`header_up X-Forwarded-For {remote_host}`) invece di accodarlo a quello inviato dal client. Le due difese ([SEC-35] e questa) reggono ciascuna da sola. *Codice:* `Caddyfile` · *Test:* `tests/unit/test_caddyfile_security_headers.py` - **[SEC-37]** Il container `app` non è pubblicato sull'host: la porta 8123 è solo dichiarata con `expose` sulla rete interna di compose, e l'unico ingresso dall'esterno è Caddy (porte 80 e 443). È questo che rende affidabile l'ultimo hop di `X-Forwarded-For`. *Codice:* `docker-compose.yml` ## Quorum e TLS verso Electrum Le regole sono di [connessione-electrum.md](connessione-electrum.md); qui il loro significato per la sicurezza. - **Quorum minimo.** Il server rifiuta di avviarsi con meno di 2 server di riserva oltre al principale ([ELC-19](connessione-electrum.md#1-rotazione-tra-server)), e ogni corroborazione richiede almeno 2 risposte e la maggioranza stretta di chi risponde ([ELC-14](connessione-electrum.md#5-il-quorum)). Il server attivo non è mai interrogato come testimone di sé stesso. Con meno di due voci indipendenti, chi controlla il server attivo — o l'unico server di riserva raggiungibile — sceglierebbe da solo il blocco di estrazione, gli accrediti, le monete date per spese e le conferme. Una rete in cui rispondono meno di 2 server ritarda le decisioni, non le prende ([ADR-07](adr/ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)). - **`ALLOW_NO_QUORUM=true`** riporta la regola ridotta (nessun altro server: la verifica passa; altrimenti basta una risposta). Rende fidato il server attivo ed è **solo per lo sviluppo**; ogni avvio lo scrive nel log come avvertimento ([ELC-19](connessione-electrum.md#1-rotazione-tra-server)). - **TLS e pin del certificato.** Il certificato di un server Electrum non è verificato contro un'autorità, perché i server usano spesso certificati autofirmati. Può però essere **fissato**: `pin=` su una voce di `ELECTRUM_FALLBACK_SERVERS` (`host:porta[:notls][:pin=]`) ed `ELECTRUM_CERT_PIN` per il principale; un certificato diverso fa fallire la connessione come un server irraggiungibile ([ELC-01](connessione-electrum.md#1-rotazione-tra-server), [ELC-03](connessione-electrum.md#1-rotazione-tra-server)). Senza pin, chi si mette in mezzo alla connessione verso un server ne prende il posto; il pin toglie questa possibilità, non quella di un server ostile in sé, contro cui resta il quorum. Una voce `notls` non ha né cifratura né pin, e conta nel quorum solo come server indipendente. - **Resta accettato** che i server del quorum siano scelti dall'operatore: più server controllati dalla stessa parte contano come voci indipendenti ([LIM-11](limiti-noti.md)). ## Host e catena di fornitura Chi controlla l'host controlla tutti i fondi ([Modello di fiducia](#modello-di-fiducia)): queste regole non lo cambiano, ma riducono ciò che ottiene chi entra con meno privilegi (un altro utente dell'host, un processo nel container, una copia di `./data/`) o chi manomette ciò che entra nell'immagine. - **[SEC-51]** Il processo del container `app` gira come un utente **non root** dedicato, dichiarato con `USER` nel `Dockerfile`. Sull'host `./data/`, le sue sottocartelle e `./secrets/` hanno permessi `0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno `0600`; tutti appartengono all'utente con cui gira il container. I file che l'app crea (database, backup, log) nascono con permessi `0600`. *Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* — *Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i file che scrive in `./data/` appartengono a root sull'host, e `.env` e i file di chiave hanno i permessi predefiniti del sistema (di norma `0644`, leggibili da ogni utente dell'host). - **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base (`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate per digest (`@sha256:…`), e le dipendenze Python si installano da un file di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o un'immagine significa rigenerare il lock o cambiare il digest in un commit. *Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* — *Stato:* da implementare (B-91) · *Oggi:* le immagini di base sono indicate per etichetta mobile e le dipendenze con `>=`: ogni build può installare versioni diverse, comprese versioni compromesse pubblicate dopo l'ultima build. La chiave di cifratura montata come file, e non come variabile d'ambiente, è [SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il proprietario ([operativita.md](operativita.md#topologia)). ## TLS e header HTTP - **[SEC-38]** Caddy termina TLS: con `SITE_ADDRESS` impostato a un dominio ottiene e rinnova un certificato Let's Encrypt; con `localhost` usa un certificato autofirmato della propria CA interna ([ARC-01](architettura.md#panoramica-del-deployment)). *Codice:* `Caddyfile`, `docker-compose.yml` - **[SEC-39]** Caddy aggiunge a ogni risposta questi header: ``` Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'" ``` *Codice:* `Caddyfile` · *Test:* `tests/unit/test_caddyfile_security_headers.py` La CSP è la principale difesa contro l'XSS, rilevante perché il JWT sta in `localStorage` e uno script iniettato potrebbe leggerlo. `script-src` e `style-src` richiedono però `'unsafe-inline'`: entrambe le SPA usano gestori `onclick` e attributi `style=""` inline ovunque, e toglierli è un refactoring a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in `style.css`/`admin.css`). I dati provenienti dal server inseriti con `innerHTML` nei due pannelli passano da una funzione `escapeHtml`. - **[SEC-40]** Il browser non usa mai una pagina o uno script di una versione precedente senza chiederne conferma al server: le pagine HTML (`/`, `/admin`, `/guida`, `/report-bug`) sono servite con `Cache-Control: no-store`, i file statici (JS, CSS) con `Cache-Control: no-cache` ([API-09](api.md#convenzioni-generali)), così una correzione di sicurezza nel frontend arriva al primo caricamento dopo il deploy. Non è configurato alcun middleware CORS: il browser consente le chiamate all'API solo dalla stessa origine. *Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`, `app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`) *Stato:* da implementare (B-94), per i file statici · *Oggi:* i file serviti dal mount di `app/static/` non hanno `Cache-Control`, solo `ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con una pagina nuova. ## Superficie esposta - **[SEC-41]** La documentazione interattiva dell'API (Swagger `/docs`, ReDoc `/redoc`, `/openapi.json`) è **disattivata** salvo `ENABLE_API_DOCS=true`, da usare solo in sviluppo: elencherebbe l'intera API, admin compreso. *Codice:* `app/config.py:Settings`, `app/main.py` · *Test:* `tests/unit/test_docs_disabled.py` - **[SEC-42]** Un errore non gestito risponde 500 con il formato di errore standard (`internal_error`) e **senza** il testo dell'eccezione, che resta solo in `logs/app.log`. Anche ogni altra risposta d'errore — admin, SSE, validazione e framework compresi — ha il formato strutturato con un codice ([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto, così una password troppo lunga non torna indietro in chiaro. *Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` *Stato:* da implementare (B-94), per gli errori diversi dal 500 · *Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE hanno `detail` come stringa, e il 422 di validazione ripete nel campo `input` il valore ricevuto. Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta di discrezione, non una protezione: la pagina è pubblica, gli endpoint no. ## Validazione degli input - **[SEC-43]** Un indirizzo fornito dall'esterno (destinazione di un prelievo, `fee_address`, QR) è accettato solo se inizia con `plm1` **e** supera la decodifica bech32 con checksum: un indirizzo valido di un'altra catena (`bc1…`) sarebbe altrimenti uno script valido su cui i fondi andrebbero persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato, e così quello verso un indirizzo del montepremi, di riserva o delle commissioni ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo)). *Codice:* `app/wallet/address.py:is_valid_plm_address`, `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py`, `tests/unit/test_round_pool_address.py` - **[SEC-44]** `GET /qr/{address}` è pubblico (lo carica un ``, che non può portare il token) ma limitato: rifiuta con 400 `invalid_address` un indirizzo più lungo di 100 caratteri o non valido ([SEC-43]) prima di generare qualunque immagine; il rendering avviene fuori dall'event loop ed è memorizzato per al più 512 indirizzi. *Codice:* `app/api/routes/qr.py:address_qr`, `app/api/routes/qr.py:_render_png` · *Test:* `tests/unit/test_qr.py` - **[SEC-45]** Un bug report ha una descrizione obbligatoria di 1–2000 caratteri (non vuota dopo il trim) e un contatto facoltativo di al più 256 caratteri. Può essere inviato anche da non autenticati, entro la quota di [SEC-55]; se il mittente è autenticato viene associato al suo account, e solo lui vede i propri report (`GET /bug-reports/mine`, autenticato). *Codice:* `app/api/routes/bug_reports.py:BugReportCreate`, `app/api/routes/bug_reports.py:create_bug_report`, `app/api/routes/bug_reports.py:list_my_bug_reports` · *Test:* `tests/unit/test_bug_reports.py` - **[SEC-46]** Gli endpoint admin che elencano dati (round, audit log, transazioni pendenti, bug report, utenti) accettano `limit` tra 1 e 500; l'elenco utenti è paginato con `after_id` ([API-33](api.md)). *Codice:* `app/api/routes/admin.py:list_rounds`, `app/api/routes/admin.py:list_audit_log`, `app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`, `app/api/routes/admin.py:list_users` · *Test:* `tests/unit/test_admin.py` *Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:* `GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza limite né paginazione. Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza SQL composto da stringhe. ## Ripristino e durabilità Un database che torna indietro rispetto alla catena — per un backup ripristinato o per un commit perso — non è solo un problema di dati: può consegnare i fondi di un utente a un altro, o far pagare due volte un round. Le regole sono dei documenti proprietari; qui il loro significato per la sicurezza. - **Indirizzi riassegnati.** Un database ripristinato non conosce gli utenti registrati dopo il backup e ne riassegnerebbe gli indici, e quindi indirizzi e fondi, ai nuovi iscritti. All'avvio si controllano i 20 indici successivi al più alto assegnato e, se uno ha cronologia in catena, la registrazione è rifiutata (`registration_unavailable`) e si scrive `restore_gap_detected` ([REG-15](registrazione-e-depositi.md#registrazione), B-90). *Oggi:* nessun controllo; l'unica difesa è la procedura [OPS-28](operativita.md#ripristino). - **Round rigiocati.** Con `START_PAUSED=true` il server parte con la lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round ha il proprio indirizzo del montepremi, le cui monete sono verificate con il quorum prima del pagamento ([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round del backup già pagato risulta impagabile invece di essere pagato due volte con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel database. - **Chiave sbagliata.** Una master key che non corrisponde al database ripristinato ferma l'avvio ([SEC-49]). - **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante è sul disco ([DAT-52](modello-dati.md), B-88): un'interruzione di corrente non può più perdere una registrazione e far riassegnare il suo indice di derivazione. ## Dati conservati e log - **[SEC-47]** Di un utente si conservano solo: username (come digitato), hash Argon2 della password, indice di derivazione, indirizzo, saldo in cache, `token_version`, data di creazione. **Nessuna email** né altro dato anagrafico. L'unico campo libero che può contenere un recapito è il `contact` facoltativo di un bug report. Gli indirizzi IP dei client non sono salvati nel database: l'app li usa nella memoria dei limitatori e compaiono solo nel log di accesso ([SEC-48]). *Codice:* `app/db/models.py:User`, `app/db/models.py:BugReport` La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md). - **[SEC-56]** **Il segreto del round è cifrato a riposo.** Fino alla rivelazione il database contiene il segreto dell'estrazione solo cifrato con Fernet, con la stessa chiave della master xprv ([modello-dati.md](modello-dati.md) DAT-64). Chi ottiene una copia del database o di un backup, senza la chiave, non può calcolare in anticipo il vincitore di un round aperto, e quindi non può pilotarlo insieme a un minatore; per farlo servono database **e** chiave, cioè lo stesso livello di accesso che già consente di spendere i fondi ([LIM-25](limiti-noti.md)). Per questo la chiave va conservata separata dai backup del database ([operativita.md](operativita.md) OPS-06). All'avvio la verifica di [SEC-49] decifra anche il segreto dei round non ancora rivelati: se non ci riesce, il server non parte. *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/keystore.py:encrypt_round_seed`, `app/wallet/keystore.py:decrypt_round_seed` · *Test:* `tests/unit/test_rounds_service.py`, `tests/unit/test_draw_proof.py` - **[SEC-48]** Nessun segreto finisce nei log o nell'audit log: né password (nemmeno quella generata dal reset), né WIF, né token, né la master key o la sua chiave di cifratura, né `server_seed` prima della rivelazione. Il log applicativo (`logs/app.log`, rotazione a 10 MB × 5 file, e lo stesso contenuto su stdout, quindi in `docker compose logs`) contiene i messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn (IP del client come lo riporta Caddy, metodo, percorso, esito: nessun percorso contiene un segreto, e gli header `Authorization`/`X-Admin-Token` non sono registrati). L'unico script che stampa un segreto è `decrypt_master_key.py`, per scelta e dopo conferma. WIF e password generate dal reset compaiono solo nella risposta HTTP all'operatore. *Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`, `app/api/routes/admin.py:reset_user_password` *Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il log va solo nel file, e il log di accesso registra l'indirizzo di Caddy, non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)). ## Rischi accettati Limiti noti e voluti, descritti in dettaglio in [limiti-noti.md](limiti-noti.md): - **Custodia totale** ([LIM-26](limiti-noti.md)). Chi controlla l'host o `ADMIN_TOKEN` controlla tutti i fondi; la cifratura a riposo e i permessi stretti non proteggono da chi ha accesso all'host. - **Operatore d'accordo con un minatore** ([LIM-25](limiti-noti.md)): possono scegliere il vincitore, perché l'operatore conosce il segreto del round prima della rivelazione. - **Attacco al 51% su PLM** ([LIM-24](limiti-noti.md)): contenuto (6 conferme sui depositi, riorganizzazioni rilevate e pausa, pagamento di ciò che è in catena), non impedito. - **Header a difficoltà minima** ([LIM-14](limiti-noti.md)): senza il ricalcolo della difficoltà, un server ostile può ancora produrre una breve catena falsa al powLimit; la neutralizzano il quorum e il commit-reveal. - **Server del quorum scelti dall'operatore** ([LIM-11](limiti-noti.md)): più server della stessa parte contano come voci indipendenti; con `ALLOW_NO_QUORUM=true` (solo sviluppo) il server attivo è fidato. - **Token admin unico e condiviso** ([LIM-06](limiti-noti.md)), senza identità per operatore né limite di tentativi: una fuga ha il raggio d'azione massimo. - **JWT in `localStorage`** ([LIM-09](limiti-noti.md)), quindi leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline. - **Limiti di frequenza parziali** ([LIM-07](limiti-noti.md)): login, registrazione, cambio password, segnalazioni di bug e SSE sì; scommesse, prelievi ed endpoint admin no. Tutti in memoria e per singolo processo. - **`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente**, visibili con `docker inspect` a chi ha accesso a Docker, che è già fidato come l'host. - **Una conferma per le transazioni della piattaforma** ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)): scommesse, pagamenti e prelievi le firma solo il server, quindi un utente non può spenderne due volte gli ingressi; solo i depositi ne richiedono 6. - **La master key non è ruotabile**: gli indirizzi degli utenti sono permanenti.