Files
davideandClaude Sonnet 5.5 575f1a5354 Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48)
- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the
  container healthcheck; the scheduler beats on every tick and draw-wait iteration
- GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation)
- GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters
- registration writes user_registered in its transaction
- the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed
  address only, so the access log shows the client's IP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:15:22 +02:00

50 KiB
Raw Permalink Blame History

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 drawing si 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 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), 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_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

  • [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. 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.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, 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.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, 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: 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 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/<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.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 (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 (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

  • [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/<round_id> 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/verify.py:verify_master_key, app/wallet/verify.py:sample_problems · Test: tests/unit/test_master_key_check.py

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.py, tests/unit/test_abuse_limits.py

  • [SEC-18] Assorbito da REG-01 e REG-02: formato dello username, confronto senza maiuscole e indice unico su lower(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, 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). Codice: app/auth/dependencies.py:get_current_user, app/api/routes/users.py:change_password, app/api/routes/admin.py:reset_user_password · Test: tests/unit/test_users.py, tests/unit/test_security.py, tests/unit/test_admin.py

  • [SEC-23] Gli endpoint raggiungibili anche da non autenticati (ad esempio GET /rounds/current, POST /bug-reports) usano l'utente solo per personalizzare la risposta: un token assente, non valido o superato li fa trattare come anonimi, senza errore. Codice: app/auth/dependencies.py:get_optional_user · Test: tests/unit/test_rounds_route.py, tests/unit/test_bug_reports.py

Autenticazione dell'operatore

Il pannello /admin è una pagina statica pubblica; la protezione sta sugli endpoint /admin/*, che richiedono l'header X-Admin-Token.

  • [SEC-24] Ogni endpoint /admin/* confronta X-Admin-Token con ADMIN_TOKEN in tempo costante (secrets.compare_digest sui byte UTF-8, così un token con caratteri non ASCII dà 403 e non 500). Se ADMIN_TOKEN è vuoto ogni richiesta è rifiutata, prima del confronto (che su due stringhe vuote darebbe esito positivo). Il rifiuto è 403 con codice invalid_admin_token (API-08). Codice: app/api/routes/admin.py:require_admin · Test: tests/unit/test_admin.py

  • [SEC-25] Il token è unico e condiviso: non esiste un'identità per singolo operatore, e l'audit log registra cosa è cambiato ma non chi l'ha fatto. Non c'è limite di tentativi né lunghezza minima imposta su ADMIN_TOKEN. Nel browser il token è tenuto in sessionStorage, quindi si perde alla chiusura della scheda. Codice: app/api/routes/admin.py:require_admin, app/static/admin.js

Il token protegge: configurazione e pausa/ripresa della lotteria, elenco utenti con saldi, export della chiave privata, reset della password, storico dei round, transazioni pendenti, audit log, bug report. L'elenco completo degli endpoint è in api.md.

  • [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. Codice: app/api/routes/admin.py:update_config, app/api/routes/admin.py:pause_lottery, app/api/routes/admin.py:resume_lottery, app/api/routes/admin.py:update_bug_report_status · Test: tests/unit/test_admin.py, tests/unit/test_bug_reports.py

  • [SEC-29] fee_address, se modificato dall'operatore, deve essere un indirizzo PLM bech32 valido (checksum e HRP plm): un indirizzo di un'altra catena manderebbe ogni commissione su uno script irraggiungibile. Anche gli altri parametri di PUT /admin/config hanno limiti minimi e massimi. Codice: app/api/routes/admin.py:RoundConfigUpdate · Test: tests/unit/test_admin.py

Limiti di frequenza

Tutti i limitatori sono in memoria, nel singolo processo: si azzerano a ogni riavvio e non funzionerebbero con più worker.

Limitatore Chiave Regola Dove
Login per username user:<username minuscolo> Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito POST /auth/login
Login per IP ip:<ip> Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. Non si azzera con un login riuscito POST /auth/login
Registrazione per IP ip:<ip> Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero POST /auth/register
Cambio password per utente (B-94) 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) 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_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:<id utente> 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 e API-21. Codice: app/api/routes/users.py:change_password, app/auth/rate_limit.py:RateLimiter, app/auth/rate_limit.py:AuthRateLimiters · Test: tests/unit/test_abuse_limits.py

  • [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). Codice: app/api/routes/bug_reports.py:create_bug_report, app/auth/rate_limit.py:RollingQuota · Test: tests/unit/test_abuse_limits.py

  • [SEC-32] La memoria dei limitatori è limitata: le voci esaurite vengono rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000 chiavi; oltre, si scartano le voci più vicine alla scadenza, mai i blocchi più recenti. Codice: app/auth/rate_limit.py:RateLimiter._prune, app/auth/rate_limit.py:RollingQuota._prune · Test: tests/unit/test_rate_limit.py

  • [SEC-33] Il canale SSE accetta al massimo 5 stream contemporanei per IP (il sesto chiude il più vecchio dello stesso IP invece di essere rifiutato) e 500 in totale (oltre, 503 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.py, tests/unit/test_abuse_limits.py

  • [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), 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_admin 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; 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=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).
  • 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 di ELECTRUM_FALLBACK_SERVERS (host:porta[:notls][:pin=<sha256>]) ed ELECTRUM_CERT_PIN per 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 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).

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 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: tests/unit/test_container_hardening.py

  • [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, 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_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). 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), 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) 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) 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 · Test: tests/unit/test_abuse_limits.py

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). 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 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). 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 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, B-90); la procedura OPS-28 toglie la sospensione.
  • Round rigiocati. Con START_PAUSED=true il 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.
  • 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, 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.

  • [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_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 · Test: tests/unit/test_observability.py

Rischi accettati

Limiti noti e voluti, descritti in dettaglio in limiti-noti.md:

  • Custodia totale (LIM-26). 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): 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_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): 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.