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>
29 KiB
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 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 e collegamenti infrastrutturali (database, percorso della master key, server Electrum, documentazione API). Cambiare un valore di.envrichiede 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.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 seXPRV_ENCRYPTION_KEYè vuota. 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. 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 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] 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.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
-
[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 seXPRV_ENCRYPTION_KEYè vuota; 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
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 sulower(username)e registrazione e login confrontanolower(...). 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 (409username_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, 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. 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/*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 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 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 |
| 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-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/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. 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 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
TLS e header HTTP
-
[SEC-38] Caddy termina TLS: con
SITE_ADDRESSimpostato ottiene e rinnova un certificato Let's Encrypt; con il valore predefinitolocalhostusa 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 conCache-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 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. 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 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. 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 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; 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
limittra 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 è ilcontactfacoltativo 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 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_password
Rischi accettati
Limiti noti e voluti, descritti in dettaglio in decisioni-e-limiti.md:
- Custodia totale. Chi controlla l'host o
ADMIN_TOKENcontrolla 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.