Files
plm-lottery/docs/sicurezza.md
T
davideandClaude Opus 5.5 61efbb0e37 Replace flowchart/ with a traceable specification in docs/
The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.

Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:16:01 +02:00

29 KiB
Raw Blame History

Sicurezza

Come PLM Lottery protegge le chiavi, le sessioni e l'accesso all'API, e quali rischi accetta consapevolmente. Il documento descrive il codice così com'è: dove un meccanismo manca, lo dice.

Per la difesa contro server Electrum ostili (validazione degli header, quorum di corroborazione) vedi connessione-electrum.md; 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 decisioni-e-limiti.md.

Modello di fiducia

La piattaforma è custodiale: tutte le chiavi private sono generate e conservate dal server. L'utente non controlla nulla finché non preleva.

Soggetto Fiducia Perché
Operatore (chi ha ADMIN_TOKEN) Totale Può esportare la chiave privata di ogni utente, reimpostare le password, cambiare l'indirizzo delle commissioni.
Host del server (chi ha accesso al filesystem o a Docker) Totale Sullo stesso host stanno la master key cifrata e la chiave che la decifra (.env).
Utenti Nessuna Ogni input è validato; ogni azione è legata al proprio account tramite JWT.
Server Electrum Nessuna Header validati e decisioni che muovono denaro corroborate da un quorum: vedi connessione-electrum.md.
Rete e client HTTP Nessuna TLS terminato da Caddy; l'IP del client è ricavato solo dall'hop scritto da Caddy.
Caddy (reverse proxy) Totale È l'unico proxy davanti all'app, e l'unica fonte di X-Forwarded-For di cui l'app si fida.
  • [SEC-01] Tutte le chiavi private (utenti e montepremi) derivano da un'unica master xprv generata e custodita dal server; nessuna chiave è mai generata o conservata lato client. Codice: app/wallet/hd.py:generate_master_key, app/wallet/hd.py:_load_account_key

Inventario dei segreti

Segreto Dove vive Come si genera Se trapela Se si perde Rotazione
Master xprv File cifrato MASTER_KEY_PATH (in Docker ./data/keys/master.xprv.enc, montato in /app/key_data/) scripts/generate_master_key.py (32 byte da os.urandom) oppure importata con scripts/encrypt_master_key.py Il file da solo è inutile senza XPRV_ENCRYPTION_KEY; insieme, chi li ha può spendere tutti i fondi (utenti e montepremi) Tutti i fondi diventano irrecuperabili, salvo un backup in chiaro ottenuto con decrypt_master_key.py Non ruotabile: gli indirizzi degli utenti sono permanenti e derivati da essa
XPRV_ENCRYPTION_KEY .env (variabile d'ambiente del container) Fernet.generate_key() (comando in .env.example) Insieme al file cifrato: come sopra Il file cifrato non è più leggibile: fondi persi senza backup della xprv Manuale: decifrare con la chiave vecchia, impostare la nuova, encrypt_master_key.py --overwrite. Non esiste uno script dedicato
JWT_SECRET .env secrets.token_urlsafe(32) Chiunque può firmare un token per qualunque utente (con il tv corretto, che è un piccolo intero) e agire come lui, prelievi compresi Nessuna perdita di dati: se ne genera uno nuovo Cambiarlo e riavviare invalida tutte le sessioni
ADMIN_TOKEN .env; nel browser dell'operatore in sessionStorage (plm_admin_token) secrets.token_urlsafe(32) Accesso completo a /admin/*, compreso l'export delle chiavi private Nessuna: se ne imposta uno nuovo Cambiarlo e riavviare; l'operatore reinserisce il token
Hash delle password Tabella users (SQLite, ./data/db/) Argon2id Attacco offline a dizionario, rallentato dai parametri Argon2 — Reset dall'operatore
JWT dell'utente Browser, localStorage (plm_token) Emesso al login/registrazione Sessione dell'utente fino alla scadenza (24h) o al cambio password — Cambio password o reset dall'operatore
  • [SEC-02] I segreti (XPRV_ENCRYPTION_KEY, JWT_SECRET, ADMIN_TOKEN) stanno solo in .env, mai nel database o nel repository; .env, i file master.xprv.enc, i database *.db, data/ e logs/ sono esclusi sia da git sia dall'immagine Docker. Codice: app/config.py:Settings, .gitignore, .dockerignore

  • [SEC-03] Tutti i parametri operativi stanno nel database (round_config); in .env restano solo segreti e collegamenti infrastrutturali (database, percorso della master key, server Electrum, documentazione API). Cambiare un valore di .env richiede un riavvio. Codice: app/config.py:Settings

.env.example documenta le chiavi necessarie e il comando per generarle; non elenca DATABASE_URL né MASTER_KEY_PATH, che in Docker sono comunque sovrascritte da docker-compose.yml.

Ciclo di vita della master key

flowchart TD
    G["generate_master_key.py<br/>seed casuale 32 byte"] --> E
    I["encrypt_master_key.py<br/>xprv importata (input nascosto)"] --> E
    E["Cifratura Fernet<br/>con XPRV_ENCRYPTION_KEY"] --> F[("master.xprv.enc<br/>./data/keys/")]
    F --> L["Primo uso a runtime:<br/>decifratura e derivazione<br/>di m/84'/746'/0'"]
    L --> U["Utenti<br/>m/84'/746'/0'/0/indice"]
    L --> P["Montepremi<br/>m/84'/746'/0'/1/0"]
    F --> D["decrypt_master_key.py<br/>stampa la xprv in chiaro<br/>(solo recupero)"]

Generazione

  • [SEC-04] La master key si crea solo con uno script manuale, mai tramite API: generate_master_key.py genera un seed casuale di 32 byte, ne ricava la xprv (versione PLM mainnet), la cifra e la scrive in MASTER_KEY_PATH. Rifiuta di sovrascrivere un file esistente; entrambi gli script di creazione rifiutano di partire se XPRV_ENCRYPTION_KEY è vuota. Codice: app/wallet/hd.py:generate_master_key, scripts/generate_master_key.py

  • [SEC-05] encrypt_master_key.py importa una xprv generata altrove: la legge con input nascosto (getpass), verifica che sia una chiave estesa valida e privata (rifiuta una xpub), e la cifra con lo stesso schema. Non può verificare che sia una chiave PLM: PLM usa gli stessi byte di versione xprv di Bitcoin. Sovrascrive un file esistente solo con --overwrite. Codice: scripts/encrypt_master_key.py

Cifratura a riposo

  • [SEC-06] Il file della master key contiene la xprv in base58 cifrata con Fernet (AES-128-CBC + HMAC-SHA256) usando XPRV_ENCRYPTION_KEY. La xprv in chiaro non viene mai scritta su disco dal server. Codice: app/wallet/keystore.py:encrypt_xprv, app/wallet/keystore.py:decrypt_xprv

La cifratura protegge da una copia del solo file (un backup di data/ finito nel posto sbagliato), non da chi ha accesso all'host: la chiave di decifratura sta nel .env accanto. Gli script non impostano permessi restrittivi sul file scritto: valgono quelli predefiniti del sistema.

Derivazione

  • [SEC-07] Gli indirizzi utente seguono BIP84 con coin type 746: m/84'/746'/0'/0/<derivation_index>, un indice per utente, P2WPKH con HRP plm. Codice: app/wallet/hd.py:derive_user_key, app/wallet/hd.py:derive_user_address, app/wallet/plm_network.py · Test: tests/unit/test_hd.py

  • [SEC-08] La chiave del montepremi è m/84'/746'/0'/1/0 (ramo 1 dell'account, indice 0): sta nella stessa master key, non è un segreto separato. Non esiste un endpoint che ne esporti la chiave privata. Codice: app/wallet/hd.py:derive_pool_key, app/wallet/hd.py:derive_pool_address

Caricamento a runtime

  • [SEC-09] La master key viene letta e decifrata al primo uso (la prima derivazione, tipicamente una registrazione o il primo pagamento), non all'avvio. Da quel momento la chiave dell'account m/84'/746'/0' resta in memoria nel processo per tutta la sua durata; la root xprv non viene conservata. Codice: app/wallet/hd.py:_load_account_key

Recupero

  • [SEC-10] decrypt_master_key.py decifra e stampa la xprv in chiaro, solo dopo una conferma esplicita (yes) e un avviso sul terminale. Se il percorso di MASTER_KEY_PATH non esiste usa ./data/keys/master.xprv.enc. Non esiste un equivalente via API o pannello. Codice: scripts/decrypt_master_key.py

Regole di avvio

  • [SEC-11] Il container rifiuta di partire se il file della master key non esiste in MASTER_KEY_PATH, prima ancora delle migrazioni. Codice: Dockerfile (comando CMD)

  • [SEC-12] All'avvio il server rifiuta di servire se JWT_SECRET ha meno di 32 caratteri o se XPRV_ENCRYPTION_KEY è vuota; tutti i problemi sono riportati insieme in un unico ConfigError. Il controllo è nel lifespan, non nella classe Settings, perché test e script importano la configurazione senza avere segreti reali. Codice: app/config.py:validate_runtime_secrets, app/main.py:lifespan · Test: tests/unit/test_config.py

  • [SEC-13] Un ADMIN_TOKEN vuoto non blocca l'avvio: il pannello risulta semplicemente chiuso (vedi [SEC-24]). Codice: app/config.py:validate_runtime_secrets · Test: tests/unit/test_config.py

Il controllo di avvio verifica che XPRV_ENCRYPTION_KEY non sia vuota, non che sia una chiave Fernet valida né che decifri davvero il file: una chiave malformata o sbagliata emerge solo al primo uso della master key ([SEC-09]).

Autenticazione degli utenti

Il flusso di registrazione e login è descritto in registrazione-e-depositi.md; qui ci sono le regole di sicurezza.

Password

  • [SEC-14] Le password sono salvate come hash Argon2id con i parametri predefiniti di argon2-cffi (PasswordHasher() senza argomenti; con la versione installata 25.1: time cost 3, memoria 64 MiB, parallelismo 4, sale 16 byte, hash 32 byte). Mai in chiaro, mai recuperabili. Codice: app/auth/security.py:hash_password · Test: tests/unit/test_security.py

  • [SEC-15] Ogni hash e verifica chiamati da codice asincrono passano da hash_password_async/verify_password_async, che eseguono Argon2 in un thread del threadpool: un login non deve bloccare l'event loop e con esso le attività in background. Le versioni sincrone restano per test e script. Codice: app/auth/security.py:hash_password_async, app/auth/security.py:verify_password_async · Test: tests/unit/test_security.py

  • [SEC-16] Qualunque errore di verifica (password sbagliata, hash illeggibile) vale come "password errata", mai come errore del server; un hash illeggibile viene anche registrato nel log. Codice: app/auth/security.py:verify_password · Test: tests/unit/test_security.py

  • [SEC-17] Lunghezza minima della password: 8 caratteri (MIN_PASSWORD_LENGTH), la stessa costante per registrazione e cambio password. Alla registrazione la password ha anche un massimo di 256 caratteri; il cambio password e il login non impongono un massimo. Codice: app/auth/security.py:MIN_PASSWORD_LENGTH, app/auth/routes.py:RegisterRequest, app/api/routes/users.py:change_password · Test: tests/unit/test_users.py

  • [SEC-18] Lo username è di 3–32 caratteri, solo A-Z a-z 0-9 _ . -, e non distingue maiuscole e minuscole: l'unicità è garantita da un indice unico su lower(username) e registrazione e login confrontano lower(...). Il nome resta salvato come digitato. Codice: app/auth/routes.py:RegisterRequest, app/auth/routes.py:register, app/auth/routes.py:login, app/db/models.py:User · Test: tests/unit/test_auth.py

  • [SEC-19] Il login risponde allo stesso modo (401 invalid_credentials) sia per uno username inesistente sia per una password errata, e i due casi alimentano lo stesso contatore di tentativi. Nota: se lo username non esiste la verifica Argon2 non viene eseguita, quindi i due casi hanno tempi di risposta diversi. La registrazione, invece, rivela per costruzione se uno username è occupato (409 username_taken). Codice: app/auth/routes.py:login · Test: tests/unit/test_auth.py

  • [SEC-20] Non esiste recupero della password né reset self-service (nessuna email è raccolta). Un utente autenticato può cambiarla solo fornendo quella attuale; altrimenti serve il reset dall'operatore ([SEC-27]). Codice: app/api/routes/users.py:change_password · Test: tests/unit/test_users.py

Token di sessione (JWT)

  • [SEC-21] Login e registrazione restituiscono un JWT HS256 firmato con JWT_SECRET, valido jwt_expire_minutes (predefinito 1440 = 24 ore). Le claim sono sub (id utente), tv (versione del token) ed exp. La decodifica accetta solo l'algoritmo configurato. Il client lo invia come Authorization: Bearer … e lo conserva in localStorage. Codice: app/auth/security.py:create_access_token, app/auth/security.py:decode_access_token · Test: tests/unit/test_security.py

  • [SEC-22] I token sono revocabili: ogni richiesta autenticata confronta la claim tv con User.token_version e rifiuta (401 session_expired) un token con una versione diversa, un token non valido o scaduto, o un utente inesistente. Il cambio password self-service e il reset dell'operatore incrementano token_version, invalidando ogni sessione precedente; il cambio password restituisce un token nuovo, così la sessione corrente resta attiva. Un token privo di tv (emesso prima della sua introduzione) vale come tv = 0. Codice: app/auth/dependencies.py:get_current_user, app/api/routes/users.py:change_password, app/api/routes/admin.py:reset_user_password · Test: tests/unit/test_users.py, tests/unit/test_security.py, tests/unit/test_admin.py

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

Autenticazione dell'operatore

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

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

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

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

  • [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
Stream SSE per IP IP Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP GET /rounds/stream
Stream SSE globale — Al massimo 500 stream contemporanei: oltre, 503 GET /rounds/stream
  • [SEC-30] Il login è bloccato se uno qualunque dei due limitatori (username o IP) è in blocco, anche con la password giusta; la risposta è 429 rate_limited con retry_after_seconds nei params. Ogni fallimento alimenta entrambi i limitatori; un successo azzera solo quello dello username. Codice: app/auth/routes.py:login, app/auth/rate_limit.py:RateLimiter, app/auth/rate_limit.py:AuthRateLimiters · Test: tests/unit/test_auth.py, tests/unit/test_rate_limit.py

  • [SEC-31] La registrazione è una quota, non un freno sui fallimenti: 5 account per IP ogni 3600s (finestra mobile). Il controllo avviene prima dell'hash Argon2; un tentativo che non crea l'account (username occupato, input non valido) non consuma la quota. Oltre la quota: 429 rate_limited con l'attesa esatta fino all'uscita dalla finestra dell'account più vecchio. Codice: app/auth/routes.py:register, app/auth/rate_limit.py:RollingQuota · Test: tests/unit/test_auth.py, tests/unit/test_rate_limit.py

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

  • [SEC-33] Il canale SSE accetta al massimo 5 stream contemporanei per IP (il sesto chiude il più vecchio dello stesso IP invece di essere rifiutato) e 500 in totale (oltre, 503; il browser ripiega sul polling). Codice: app/rounds/events.py:RoundEventBroadcaster.subscribe, app/api/routes/rounds.py:round_stream · Test: tests/unit/test_round_events.py

  • [SEC-34] Non hanno limiti di frequenza: scommesse, prelievi, cambio password (tentativi sulla password attuale compresi), endpoint admin (tentativi sul token compresi), invio di bug report (anche anonimi), QR, GET /rounds/current e gli altri endpoint di lettura. Scommesse e prelievi sono serializzati per utente dal blocco per-utente (vedi scommesse-e-prelievi.md), che non è un limite di frequenza. Codice: app/api/routes/bets.py, app/api/routes/withdrawals.py, app/api/routes/users.py:change_password, app/api/routes/bug_reports.py:create_bug_report

Indirizzo IP del client

Ogni controllo basato sull'IP (limitatori di login e registrazione, limite SSE per IP) usa la stessa funzione.

  • [SEC-35] L'IP del client è l'ultimo elemento di X-Forwarded-For (quello scritto dall'hop più vicino, cioè Caddy), non il primo, che il client può falsificare. Senza l'header si usa l'indirizzo della connessione, e in mancanza anche di quello unknown. Codice: app/api/client_ip.py:client_ip · Test: tests/unit/test_client_ip.py

  • [SEC-36] Caddy sovrascrive X-Forwarded-For con l'indirizzo reale del peer (header_up X-Forwarded-For {remote_host}) invece di accodarlo a quello inviato dal client. Le due difese ([SEC-35] e questa) reggono ciascuna da sola. Codice: Caddyfile · Test: tests/unit/test_caddyfile_security_headers.py

  • [SEC-37] Il container app non è pubblicato sull'host: la porta 8123 è solo dichiarata con expose sulla rete interna di compose, e l'unico ingresso dall'esterno è Caddy (porte 80 e 443). È questo che rende affidabile l'ultimo hop di X-Forwarded-For. Codice: docker-compose.yml

TLS e header HTTP

  • [SEC-38] Caddy termina TLS: con SITE_ADDRESS impostato ottiene e rinnova un certificato Let's Encrypt; con il valore predefinito localhost usa un certificato autofirmato della propria CA interna. Codice: Caddyfile, docker-compose.yml

  • [SEC-39] Caddy aggiunge a ogni risposta questi header:

    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "strict-origin-when-cross-origin"
    Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
    

    Codice: Caddyfile · Test: tests/unit/test_caddyfile_security_headers.py

La CSP è la principale difesa contro l'XSS, rilevante perché il JWT sta in localStorage e uno script iniettato potrebbe leggerlo. script-src e style-src richiedono però 'unsafe-inline': entrambe le SPA usano gestori onclick e attributi style="" inline ovunque, e toglierli è un refactoring a sé. L'unica risorsa esterna ammessa è Google Fonts (l'@import in style.css/admin.css). I dati provenienti dal server inseriti con innerHTML nei due pannelli passano da una funzione escapeHtml.

  • [SEC-40] Le pagine HTML (/, /admin, /guida, /report-bug) sono servite con Cache-Control: no-store. Non è configurato alcun middleware CORS: il browser consente le chiamate all'API solo dalla stessa origine. Codice: app/main.py:index_page, app/main.py:admin_panel

Superficie esposta

  • [SEC-41] La documentazione interattiva dell'API (Swagger /docs, ReDoc /redoc, /openapi.json) è disattivata salvo ENABLE_API_DOCS=true, da usare solo in sviluppo: elencherebbe l'intera API, admin compreso. Codice: app/config.py:Settings, app/main.py · Test: tests/unit/test_docs_disabled.py

  • [SEC-42] Un errore non gestito risponde 500 con il formato di errore standard (internal_error) e senza il testo dell'eccezione, che resta solo in logs/app.log. Codice: app/main.py:log_unhandled_exception

Il pannello /admin non è collegato da / (né viceversa), ma è una scelta di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.

Validazione degli input

  • [SEC-43] Un indirizzo fornito dall'esterno (destinazione di un prelievo, fee_address, QR) è accettato solo se inizia con plm1 e supera la decodifica bech32 con checksum: un indirizzo valido di un'altra catena (bc1…) sarebbe altrimenti uno script valido su cui i fondi andrebbero persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato. Codice: app/wallet/address.py:is_valid_plm_address, app/withdrawals/service.py:request_withdrawal · Test: tests/unit/test_withdrawals.py, tests/unit/test_qr.py

  • [SEC-44] GET /qr/{address} è pubblico (lo carica un <img>, che non può portare il token) ma limitato: rifiuta con 400 invalid_address un indirizzo più lungo di 100 caratteri o non valido ([SEC-43]) prima di generare qualunque immagine; il rendering avviene fuori dall'event loop ed è memorizzato per al più 512 indirizzi. Codice: app/api/routes/qr.py:address_qr, app/api/routes/qr.py:_render_png · Test: tests/unit/test_qr.py

  • [SEC-45] Un bug report ha una descrizione obbligatoria di 1–2000 caratteri (non vuota dopo il trim) e un contatto facoltativo di al più 256 caratteri. Può essere inviato anche da non autenticati; se il mittente è autenticato viene associato al suo account, e solo lui vede i propri report (GET /bug-reports/mine, autenticato). Codice: app/api/routes/bug_reports.py:BugReportCreate, app/api/routes/bug_reports.py:create_bug_report, app/api/routes/bug_reports.py:list_my_bug_reports · Test: tests/unit/test_bug_reports.py

  • [SEC-46] Gli endpoint admin che elencano dati (round, audit log, transazioni pendenti, bug report) accettano limit tra 1 e 500. L'elenco utenti non è paginato. Codice: app/api/routes/admin.py:list_rounds, app/api/routes/admin.py:list_audit_log, app/api/routes/admin.py:list_pending_transactions, app/api/routes/admin.py:list_bug_reports, app/api/routes/admin.py:list_users · Test: tests/unit/test_admin.py

Tutto l'accesso al database passa dall'ORM di SQLAlchemy con parametri, senza SQL composto da stringhe.

Dati conservati e log

  • [SEC-47] Di un utente si conservano solo: username (come digitato), hash Argon2 della password, indice di derivazione, indirizzo, saldo in cache, token_version, data di creazione. Nessuna email né altro dato anagrafico. L'unico campo libero che può contenere un recapito è il contact facoltativo di un bug report. Gli indirizzi IP dei client non sono salvati nel database: l'app li usa solo nella memoria dei limitatori. Codice: app/db/models.py:User, app/db/models.py:BugReport

La struttura completa delle tabelle è in modello-dati.md.

  • [SEC-48] Nessun segreto finisce nei log o nell'audit log: né password (nemmeno quella generata dal reset), né WIF, né token, né la master key. Il log applicativo (logs/app.log, rotazione a 10 MB × 5 file) contiene i messaggi dell'app e di uvicorn, incluso il log di accesso di uvicorn (indirizzo del peer diretto, metodo, percorso, esito: nessun percorso contiene un segreto, e gli header Authorization/X-Admin-Token non sono registrati). L'unico script che stampa un segreto è decrypt_master_key.py, per scelta e dopo conferma. WIF e password generate dal reset compaiono solo nella risposta HTTP all'operatore. Codice: app/logging_config.py:setup_logging, app/api/routes/admin.py:user_privkey, app/api/routes/admin.py:reset_user_password

Rischi accettati

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

  • Custodia totale. Chi controlla l'host o ADMIN_TOKEN controlla tutti i fondi; la cifratura a riposo non protegge da chi ha accesso all'host.
  • Token admin unico e condiviso, senza identità per operatore né limite di tentativi: una fuga ha il raggio d'azione massimo.
  • JWT in localStorage, quindi leggibile da un eventuale XSS; la CSP lo mitiga ma ammette script inline.
  • Limiti di frequenza solo su login, registrazione e SSE, in memoria e per singolo processo.
  • Nessuna corroborazione se ELECTRUM_FALLBACK_SERVERS è vuoto: il server Electrum principale diventa fidato (vedi connessione-electrum.md).
  • Una sola conferma per ogni transazione, con il rischio di riorganizzazione accettato e nessuna logica di rollback.
  • La master key non è ruotabile: gli indirizzi degli utenti sono permanenti.