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