- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it and closes it to others (works on an older root-owned install with no host sudo); files are created 0600 (umask 077) - python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from requirements.lock with --require-hashes - the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose secret), out of every environment variable and of a ./data backup - setup.md no longer presents a second way to run the server (B-72) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
51 KiB
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 e processo-di-modifica.md).
Per la difesa contro server Electrum ostili (validazione degli header, quorum di corroborazione) vedi connessione-electrum.md; per l'estrazione ciclo-di-vita-del-round.md; per gli endpoint e i codici di errore api.md; per l'elenco degli eventi di audit audit-log.md; per backup e procedure operative operativita.md; per le decisioni di progetto e i limiti accettati 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, LIM-26).
| 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. |
| 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 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).
- Come il commit-reveal toglie il movente al minatore. All'apertura il
server si impegna su un segreto pubblicandone solo l'impronta
(RND-34); all'ingresso in
drawingsi fissano l'elenco dei partecipanti e l'altezza del blocco (RND-33); 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). 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). - Header falsi. Il bersaglio che un header dichiara nel campo
bitslo sceglie chi fabbrica l'header (conbits = 0x207fffffun 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), 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).
Restano accettati, e descritti in limiti-noti.md:
- Operatore d'accordo con un minatore (LIM-25). 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): chi può leggere il segreto dal database può già spendere ogni moneta.
- Un attacco al 51% su PLM (LIM-24) è contenuto, non impedito: 6 conferme sui depositi (ADR-06, B-82), rilevamento delle riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete del round prima del pagamento (ADR-52, 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. 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, [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) | 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 |
JWT_SECRET e ADMIN_TOKEN restano variabili d'ambiente (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_SECRETeADMIN_TOKENin.env, la chiave di cifratura della master key nel proprio file ([SEC-53])..env, la cartellasecrets/, i filemaster.xprv.enc, i database*.db,data/elogs/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.envrestano 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.envrichiede un riavvio. L'elenco completo è in architettura.md. 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
flowchart TD
G["generate_master_key.py<br/>seed casuale 32 byte"] --> E
I["encrypt_master_key.py<br/>xprv importata (input nascosto)<br/>confrontata con il database (SEC-50)"] --> E
E["Cifratura Fernet<br/>con la chiave di XPRV_ENCRYPTION_KEY_FILE"] --> F[("master.xprv.enc<br/>./data/keys/, 0600")]
F --> L["Avvio del server:<br/>decifratura, derivazione di m/84'/746'/0'<br/>e confronto con il database (SEC-49)"]
L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
L --> P["Montepremi del round<br/>m/84'/746'/0'/2/round_id"]
L --> R["Riserva del montepremi<br/>m/84'/746'/0'/1/0"]
F --> D["decrypt_master_key.py<br/>stampa la xprv in chiaro<br/>(solo recupero)"]
Generazione
-
[SEC-04] La master key si crea solo con uno script manuale, mai tramite API:
generate_master_key.pygenera un seed casuale di 32 byte, ne ricava la xprv (versione PLM mainnet), la cifra e la scrive inMASTER_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.pyimporta 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_URLprima di scrivere:generate_master_key.pyrifiuta se il database contiene già utenti o round, perché una chiave nuova non ne riprodurrebbe gli indirizzi;encrypt_master_key.pyricalcola 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 permessi0600(senza passare da permessi più larghi) e lo riscrivono con gli stessi permessi. Codice:scripts/generate_master_key.py,scripts/encrypt_master_key.py,app/wallet/hd.py:generate_master_key,app/wallet/verify.py· Test:tests/unit/test_master_key_check.py
Cifratura a riposo
- [SEC-06] Il file della master key contiene la xprv in base58 cifrata con
Fernet (AES-128-CBC + HMAC-SHA256) usando la chiave di cifratura
([SEC-53]). La xprv in chiaro non viene mai scritta su disco dal server.
Codice:
app/wallet/keystore.py:encrypt_xprv,app/wallet/keystore.py:decrypt_xprv
La cifratura protegge da una copia del solo file (un backup di data/
finito nel posto sbagliato), non da chi ha accesso all'host: la chiave di
decifratura sta sullo stesso host. Tenerla fuori da ./data/ ([SEC-53]) fa
sì che un backup dei dati non contenga entrambe.
- [SEC-53] La chiave di cifratura della master key è
un file,
./secrets/xprv_encryption.keysull'host (cartella0700, file0600, fuori da./data/), montato in sola lettura come secret di compose in/run/secrets/xprv_encryption_key; l'app legge il percorso daXPRV_ENCRYPTION_KEY_FILE(architettura.md, ARC-05, ARC-30). La chiave non compare in alcuna variabile d'ambiente del container, quindidocker inspectnon 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:tests/unit/test_container_hardening.py,tests/unit/test_config.py
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 HRPplm. Codice:app/wallet/hd.py:derive_user_key,app/wallet/hd.py:derive_user_address,app/wallet/plm_network.py· Test:tests/unit/test_hd.py -
[SEC-08] Le chiavi del montepremi — una per round,
m/84'/746'/0'/2/<round_id>, e quella di riserva,m/84'/746'/0'/1/0(REG-14) — stanno nella stessa master key, non sono segreti separati. Non esiste un endpoint che ne esporti la chiave privata. Codice:app/wallet/hd.py:derive_pool_key,app/wallet/hd.py:derive_pool_address,app/wallet/hd.py:derive_round_pool_key· Test:tests/unit/test_round_pool_address.py
Caricamento a runtime
- [SEC-09] La master key viene letta e decifrata all'avvio, nel
lifespan, prima di creare le attività in background, ed è subito
verificata ([SEC-49]). Da quel momento la chiave dell'account
m/84'/746'/0'resta in memoria nel processo per tutta la sua durata; la root xprv non viene conservata. Codice:app/wallet/hd.py:_load_account_key,app/main.py:lifespan· Test:tests/unit/test_master_key_check.py
Recupero
- [SEC-10]
decrypt_master_key.pydecifra e stampa la xprv in chiaro, solo dopo una conferma esplicita (yes) e un avviso sul terminale. Se il percorso diMASTER_KEY_PATHnon 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 (ARC-19, ARC-60); qui ci sono quelli di sicurezza. Il rifiuto di partire con meno di 2 server Electrum di riserva è ELC-19 (vedi 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(comandoCMD) -
[SEC-12] All'avvio il server rifiuta di servire se
JWT_SECRETha meno di 32 caratteri o se la chiave di cifratura della master key è vuota (il file indicato daXPRV_ENCRYPTION_KEY_FILEmanca o è vuoto); tutti i problemi sono riportati insieme in un unicoConfigError. Il controllo è nel lifespan, non nella classeSettings, 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_TOKENvuoto 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 ilpool_addressdi ogni round che lo usa; - gli indirizzi degli utenti con i 10
derivation_indexpiù bassi e i 10 più alti (tutti, se gli utenti sono meno di 20), conusers.address; - gli indirizzi
m/84'/746'/0'/2/<round_id>degli ultimi 10 round peridche hanno un indirizzo proprio, con il loropool_address; - il
server_seed_encdi ogni round non ancora rivelato, che deve decifrarsi e averesha256uguale al suoserver_seed_hash([SEC-56]).
Se la chiave non si decifra (chiave di cifratura sbagliata o malformata, file corrotto) o un solo indirizzo differisce, il server rifiuta di servire: il lifespan scrive nel log quale controllo è fallito (tipo e indice o id, mai la chiave) e il processo termina. Un database senza utenti né round non ha nulla da confrontare: basta che la chiave si decifri. Lo scopo è non partire con una master key diversa da quella che ha prodotto gli indirizzi salvati, che sposterebbe in silenzio il montepremi e farebbe firmare transazioni che la rete rifiuta. Codice:
app/main.py:lifespan,app/wallet/verify.py:verify_master_key,app/wallet/verify.py:sample_problems· Test:tests/unit/test_master_key_check.py - l'indirizzo di riserva
Autenticazione degli utenti
Il flusso di registrazione, login e cambio password è descritto in registrazione-e-depositi.md, 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, REG-09 e REG-12. 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.pyStato: 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 e REG-02: formato dello username, confronto senza maiuscole e indice unico sulower(username)sono regole del dominio, con la motivazione in ADR-38; qui non resta nulla di specifico della sicurezza. -
[SEC-19] Login con username inesistente e con password errata danno la stessa risposta (REG-09) 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 e REG-13: l'assenza di recupero, il cambio con la password attuale e il reset dall'operatore sono il flusso del dominio (motivazione in ADR-37); 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, validojwt_expire_minutes(predefinito 1440 = 24 ore). Le claim sonosub(id utente),tv(versione del token) edexp. La decodifica accetta solo l'algoritmo configurato. Il client lo invia comeAuthorization: Bearer …e lo conserva inlocalStorage. 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
tvconUser.token_versione rifiuta (401session_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 incrementanotoken_version, invalidando ogni sessione precedente; il cambio password restituisce un token nuovo, così la sessione corrente resta attiva. Un token privo ditv(emesso prima della sua introduzione) vale cometv = 0. Una richiesta senza headerAuthorizationrisponde 401not_authenticated(API-06). 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.pyStato: da implementare (B-94), pernot_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/*confrontaX-Admin-TokenconADMIN_TOKENin tempo costante (secrets.compare_digestsui byte UTF-8, così un token con caratteri non ASCII dà 403 e non 500). SeADMIN_TOKENè vuoto ogni richiesta è rifiutata, prima del confronto (che su due stringhe vuote darebbe esito positivo). Il rifiuto è 403 con codiceinvalid_admin_token(API-08). Codice:app/api/routes/admin.py:require_admin· Test:tests/unit/test_admin.pyStato: 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 insessionStorage, 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.
-
[SEC-26]
GET /admin/users/{id}/privkeyesporta 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 scriveadmin_privkey_accessednell'audit log (con l'id utente, mai la chiave). Il pannello mostra la WIF contextContent, 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-passwordimposta una password casuale (secrets.token_urlsafe(12), 16 caratteri), la restituisce una sola volta perché l'operatore la comunichi, incrementatoken_version([SEC-22]) e scriveadmin_password_resetnell'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, epausednon è modificabile daPUT /admin/config), cambio di stato di un bug report (bug_report_status_changed). Il catalogo completo è in 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 HRPplm): un indirizzo di un'altra catena manderebbe ogni commissione su uno script irraggiungibile. Anche gli altri parametri diPUT /admin/confighanno limiti minimi e massimi. Codice:app/api/routes/admin.py:RoundConfigUpdate· Test:tests/unit/test_admin.py
Limiti di frequenza
Tutti i limitatori sono in memoria, nel singolo processo: si azzerano a ogni riavvio e non funzionerebbero con più worker.
| Limitatore | Chiave | Regola | Dove |
|---|---|---|---|
| Login per username | user:<username minuscolo> |
Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | POST /auth/login |
| Login per IP | ip:<ip> |
Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. Non si azzera con un login riuscito | POST /auth/login |
| Registrazione per IP | ip:<ip> |
Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | POST /auth/register |
| Cambio password per utente (B-94, da implementare) | user:<id utente>, in un limitatore separato da quello del login |
Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | POST /users/me/change-password |
| Segnalazioni di bug per IP (B-94, da implementare) | ip:<ip> |
Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | POST /bug-reports |
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | GET /rounds/stream |
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | GET /rounds/stream |
-
[SEC-30] Il login è bloccato se uno qualunque dei due limitatori (username o IP) è in blocco, anche con la password giusta; la risposta è 429
rate_limitedconretry_after_secondsneiparams. 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_limitedcon 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_passwordsbagliata registra un fallimento sulla chiaveuser:<id utente>di unRateLimiterdedicato (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 è 429rate_limitedconretry_after_secondsneiparams. Serve a chi ha rubato un token valido e vuole scoprire la password, per poi cambiarla e chiudere fuori il proprietario. L'eventopassword_changede l'ordine degli errori sono di REG-12 e API-21. 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
RollingQuotadedicato), anche per un chiamante autenticato. Il controllo avviene prima dell'inserimento; un invio rifiutato per validazione non consuma la quota. Oltre la quota: 429rate_limitedcon l'attesa esatta fino all'uscita dalla finestra della segnalazione più vecchia (API-28). 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; 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.pyStato: da implementare (B-94), per il codicetoo_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/currente gli altri endpoint di lettura. Scommesse e prelievi sono serializzati per utente dal blocco per-utente (vedi scommesse-e-prelievi.md), che non è un limite di frequenza (LIM-07). Codice:app/api/routes/bets.py,app/api/routes/withdrawals.py,app/api/routes/admin.py:require_adminStato: 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 quellounknown. Codice:app/api/client_ip.py:client_ip· Test:tests/unit/test_client_ip.py -
[SEC-36] Caddy sovrascrive
X-Forwarded-Forcon 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
appnon è pubblicato sull'host: la porta 8123 è solo dichiarata conexposesulla rete interna di compose, e l'unico ingresso dall'esterno è Caddy (porte 80 e 443). È questo che rende affidabile l'ultimo hop diX-Forwarded-For. Codice:docker-compose.yml
Quorum e TLS verso Electrum
Le regole sono di 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), e ogni corroborazione richiede almeno 2 risposte e la maggioranza stretta di chi risponde (ELC-14). 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).
ALLOW_NO_QUORUM=trueriporta 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).- TLS e pin del certificato. Il certificato di un server Electrum non è
verificato contro un'autorità, perché i server usano spesso certificati
autofirmati. Può però essere fissato:
pin=<sha256>su una voce diELECTRUM_FALLBACK_SERVERS(host:porta[:notls][:pin=<sha256>]) edELECTRUM_CERT_PINper il principale; un certificato diverso fa fallire la connessione come un server irraggiungibile (ELC-01, ELC-03). 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 vocenotlsnon 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).
Host e catena di fornitura
Chi controlla l'host controlla tutti i fondi (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
appgira come un utente non root dedicato, dichiarato conUSERnelDockerfile. Sull'host./data/, le sue sottocartelle e./secrets/hanno permessi0700;.env,master.xprv.ence./secrets/xprv_encryption.keyhanno0600; tutti appartengono all'utente con cui gira il container. I file che l'app crea (database, backup, log) nascono con permessi0600. Codice:Dockerfile,docker-compose.yml· Test:tests/unit/test_container_hardening.py -
[SEC-52] Ciò che entra nelle immagini è fissato: le immagini di base (
python:3.12-slimperapp,caddy:2-alpinepercaddy) 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>=dipyproject.toml. Aggiornare una dipendenza o un'immagine significa rigenerare il lock o cambiare il digest in un commit. Codice:Dockerfile,docker-compose.yml,requirements.lock· Test:tests/unit/test_container_hardening.py
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).
TLS e header HTTP
-
[SEC-38] Caddy termina TLS: con
SITE_ADDRESSimpostato a un dominio ottiene e rinnova un certificato Let's Encrypt; conlocalhostusa un certificato autofirmato della propria CA interna (ARC-01). 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 conCache-Control: no-store, i file statici (JS, CSS) conCache-Control: no-cache(API-09), 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 diapp/static/non hannoCache-Control, soloETag/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 salvoENABLE_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 inlogs/app.log. Anche ogni altra risposta d'errore — admin, SSE, validazione e framework compresi — ha il formato strutturato con un codice (API-16) 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.pyStato: da implementare (B-94), per gli errori diversi dal 500 · Oggi: 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE hannodetailcome stringa, e il 422 di validazione ripete nel campoinputil 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 conplm1e 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). Codice:app/wallet/address.py:is_valid_plm_address,app/withdrawals/service.py:request_withdrawal· Test:tests/unit/test_withdrawals.py,tests/unit/test_qr.py,tests/unit/test_round_pool_address.py -
[SEC-44]
GET /qr/{address}è pubblico (lo carica un<img>, che non può portare il token) ma limitato: rifiuta con 400invalid_addressun 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
limittra 1 e 500; l'elenco utenti è paginato conafter_id(API-33). 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.pyStato: da implementare (B-94), per l'elenco utenti · Oggi:GET /admin/usersrestituisce 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 scriverestore_gap_detected(REG-15, B-90); la procedura OPS-28 toglie la sospensione. - Round rigiocati. Con
START_PAUSED=trueil server parte con la lotteria in pausa (OPS-27, B-90), e ogni round ha il proprio indirizzo del montepremi, le cui monete sono verificate con il quorum prima del pagamento (RND-35, 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=FULLun commit confermato al chiamante è sul disco (DAT-52, 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 è ilcontactfacoltativo 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.
- [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 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).
Per questo la chiave va conservata separata dai backup del database
(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_seedprima della rivelazione. Il log applicativo (logs/app.log, rotazione a 10 MB × 5 file, e lo stesso contenuto su stdout, quindi indocker 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 headerAuthorization/X-Admin-Tokennon 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_passwordStato: 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).
Rischi accettati
Limiti noti e voluti, descritti in dettaglio in limiti-noti.md:
- Custodia totale (LIM-26). Chi controlla l'host
o
ADMIN_TOKENcontrolla 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): possono scegliere il vincitore, perché l'operatore conosce il segreto del round prima della rivelazione.
- Attacco al 51% su PLM (LIM-24): contenuto (6 conferme sui depositi, riorganizzazioni rilevate e pausa, pagamento di ciò che è in catena), non impedito.
- Header a difficoltà minima (LIM-14): 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):
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), senza identità per operatore né limite di tentativi: una fuga ha il raggio d'azione massimo.
- JWT in
localStorage(LIM-09), quindi leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline. - Limiti di frequenza parziali (LIM-07): login, registrazione, cambio password, segnalazioni di bug e SSE sì; scommesse, prelievi ed endpoint admin no. Tutti in memoria e per singolo processo.
JWT_SECRETeADMIN_TOKENrestano variabili d'ambiente, visibili condocker inspecta chi ha accesso a Docker, che è già fidato come l'host.- Una conferma per le transazioni della piattaforma (ADR-06): 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.