Harden the container: non-root, pinned images and dependencies, the key in a file (B-91, SEC-51, SEC-52, SEC-53; closes B-71, B-72)

- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it
  and closes it to others (works on an older root-owned install with no host sudo);
  files are created 0600 (umask 077)
- python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from
  requirements.lock with --require-hashes
- the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose
  secret), out of every environment variable and of a ./data backup
- setup.md no longer presents a second way to run the server (B-72)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 00:54:23 +02:00
1 parent 40ac490826
commit c1b192f6d1
23 files changed
+1500 -183

No files matched your search

+1
View File
@@ -11,3 +11,4 @@ data/
*.egg-info/ *.egg-info/
tests/ tests/
.claude/ .claude/
secrets/
+8 -3
View File
@@ -30,9 +30,14 @@ ELECTRUM_CERT_PIN=
# NEVER true in production. # NEVER true in production.
ALLOW_NO_QUORUM=false ALLOW_NO_QUORUM=false
# Fernet key protecting the master xprv at rest. Generate with: # File holding the Fernet key that protects the master xprv at rest (B-91): in a file, not in
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # an environment variable, so `docker inspect` cannot show it and a backup of ./data does not
XPRV_ENCRYPTION_KEY= # carry both. Inside the container docker-compose.yml mounts it as a secret and overrides
# this path. Create it once:
# mkdir -p secrets && chmod 700 secrets
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key
# chmod 600 secrets/xprv_encryption.key
XPRV_ENCRYPTION_KEY_FILE=./secrets/xprv_encryption.key
# Random secret for JWT session signing. Generate with: # Random secret for JWT session signing. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))" # python -c "import secrets; print(secrets.token_urlsafe(32))"
+1
View File
@@ -8,3 +8,4 @@ master.xprv.enc
*.egg-info/ *.egg-info/
logs/ logs/
data/ data/
secrets/
+5 -85
View File
@@ -1,77 +1,10 @@
# BUGS.md — open findings # BUGS.md — open findings
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
2026-09-28 specification review (B-73 … B-98, at the end of the file). (B-92, B-93, B-94, B-96). A finding is removed from this file once it is fixed, and
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
## Audit of 2026-08-03 convention each entry gets its own commit with its own regression test, and the `B-nn`
marker goes in a comment next to the fix.
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
fixed finding, B-51.
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
removed from this file once it is fixed, and is not listed here afterwards. Per
CLAUDE.md's convention each entry gets its own commit with its own regression test,
and the `B-nn` marker goes in a comment next to the fix, so
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
still empty; withdrawal and the RBF bump path still never live-broadcast.
Verified as *not* broken while looking for these: i18n key parity (146 identical
keys across all 7 languages), HTML escaping of every user-controlled value in
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
secrets/DB/logs (nothing sensitive is tracked in git).
Severity is about consequence, not likelihood:
**critical** = money stuck or lost, or the lottery stops;
**high** = a security control that does not hold;
**medium** = wrong behaviour with a bounded blast radius;
**low** = drift between documentation and code.
---
## Critical
### (not new) `drawing` does not resume after a restart
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
here. Worth restating in context: with `restart: unless-stopped` on the app
container, this is the one state that gets stuck with money in play, and it
remains the last prerequisite for running unattended.
---
## Low — documentation and consistency drift
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
working tree (both gitignored). They were verified during this audit to decrypt to
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
reads a different file from the one the container uses, and a future
`generate_master_key.py --overwrite` would split them silently, with an ops
recovery path that then reports the wrong key.
The same duplication exists for the database (`./plm_lottery.db` next to
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
and describes the master-key step as local *or* Docker, while B-44 made Docker the
only supported way to run the server (`docs/running-the-server.md` and README were
updated, this file was not). The venv genuinely is needed for tests, migrations and
the key scripts — the wording just needs to say that instead of implying a second
way to run the server.
---
# Audit of 2026-09-28 — specification review (B-73 … B-98) # Audit of 2026-09-28 — specification review (B-73 … B-98)
@@ -100,19 +33,6 @@ decisions, see `docs/limiti-noti.md`):
- **Contain reorgs instead of pretending they don't happen:** detect them, pause - **Contain reorgs instead of pretending they don't happen:** detect them, pause
on deep ones, and pay out only what is verifiably on chain. on deep ones, and pay out only what is verifiably on chain.
## High
### B-91 — Host and supply-chain hardening
The container runs as root, `.env` and key files are world-readable, dependencies are
unpinned `>=` and base images float, secrets sit in env vars visible to
`docker inspect`.
Fix: non-root `USER`, `0600`/`0700` permissions, a hash-pinned lock file,
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
## Medium ## Medium
### B-92 — No health, readiness or real audit browsing ### B-92 — No health, readiness or real audit browsing
+6 -6
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status ## Project status
All 10 stages of the original build order are code-complete and unit-tested — 719 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. All 10 stages of the original build order are code-complete and unit-tested — 728 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only. Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -44,23 +44,23 @@ pip install -e ".[dev]"
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env) PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs the key file `XPRV_ENCRYPTION_KEY_FILE` points at)
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation) PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace) PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 719 tests python -m pytest # all 728 tests
python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
``` ```
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`. `asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set. `.env` (gitignored) holds the real secrets (except the Fernet key, which is `secrets/xprv_encryption.key`); `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
## Deployment (Docker + Caddy) ## Deployment (Docker + Caddy)
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key. Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Three services: a one-shot `data-permissions` (root, chowns `./data` to uid 1000 and closes it to others, B-91), `app` (non-root uid 1000; this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
```bash ```bash
mkdir -p data/db data/keys data/logs # one-time mkdir -p data/db data/keys data/logs # one-time
@@ -78,7 +78,7 @@ The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Option
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing. - Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL). - **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts. - Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one. - Secrets: master xprv Fernet-encrypted at rest, encryption key in its own file, `./secrets/xprv_encryption.key` (`XPRV_ENCRYPTION_KEY_FILE`; a compose secret in the container, never in the DB, git or an environment variable — B-91). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or the encryption key file is missing or empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway. - **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
## PLM network parameters (mainnet) ## PLM network parameters (mainnet)
+13 -3
View File
@@ -1,15 +1,25 @@
FROM python:3.12-slim # B-91 (SEC-51, SEC-52): base image pinned by digest, dependencies installed from a
# hash-checked lock file, process running as a non-root user.
# To update: `docker buildx imagetools inspect python:3.12-slim` for the digest, and
# `pip-compile --generate-hashes --strip-extras -o requirements.lock pyproject.toml` for the lock.
FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
WORKDIR /app WORKDIR /app
COPY requirements.lock ./
RUN pip install --no-cache-dir --require-hashes --no-deps -r requirements.lock
COPY pyproject.toml ./ COPY pyproject.toml ./
COPY app ./app COPY app ./app
COPY migrations ./migrations COPY migrations ./migrations
COPY alembic.ini ./ COPY alembic.ini ./
COPY scripts ./scripts COPY scripts ./scripts
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir --no-deps . \
&& useradd --uid 1000 --no-create-home --shell /usr/sbin/nologin app
USER 1000:1000
EXPOSE 8123 EXPOSE 8123
CMD ["/bin/sh", "-c", "test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"] # umask 077: the database, its WAL files and the logs are created readable by the app user only.
CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file python -m pytest tests/unit/test_hd.py # one file
``` ```
719 unit tests cover HD derivation, PSBT building, the Electrum client, bets, 728 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet automated integration tests against a live Electrum connection — mainnet
+18 -1
View File
@@ -19,6 +19,14 @@ logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
def model_post_init(self, __context) -> None:
if self.xprv_encryption_key_file and not self.xprv_encryption_key:
try:
with open(self.xprv_encryption_key_file) as f:
self.xprv_encryption_key = f.read().strip()
except OSError:
pass # validate_runtime_secrets reports it, naming the file
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db" database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
electrum_host: str = "santantonio.sytes.net" electrum_host: str = "santantonio.sytes.net"
@@ -45,6 +53,10 @@ class Settings(BaseSettings):
start_paused: bool = False start_paused: bool = False
xprv_encryption_key: str = "" xprv_encryption_key: str = ""
# B-91 (SEC-53): the key as a file (a compose secret in the container), so it is in no
# environment variable and `docker inspect` cannot show it. When set it is read into
# `xprv_encryption_key` at load, which is what the rest of the code uses.
xprv_encryption_key_file: str = ""
master_key_path: str = "./master.xprv.enc" master_key_path: str = "./master.xprv.enc"
jwt_secret: str = "" jwt_secret: str = ""
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
@@ -95,8 +107,13 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")' '(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
) )
if not config.xprv_encryption_key.strip(): if not config.xprv_encryption_key.strip():
where = (
f"XPRV_ENCRYPTION_KEY_FILE ({config.xprv_encryption_key_file}) is missing or empty"
if config.xprv_encryption_key_file
else "XPRV_ENCRYPTION_KEY_FILE must point to a file holding the key"
)
problems.append( problems.append(
"XPRV_ENCRYPTION_KEY must be set (generate: python -c " where + " (generate: python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")' '"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
) )
if problems: if problems:
+1 -1
View File
@@ -115,7 +115,7 @@ def verify_master_key() -> None:
except Exception as exc: except Exception as exc:
raise ConfigError( raise ConfigError(
f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): " f"the master key at {settings.master_key_path} cannot be loaded ({type(exc).__name__}): "
"check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY" "check MASTER_KEY_PATH and XPRV_ENCRYPTION_KEY_FILE"
) from exc ) from exc
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key) problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
if problems: if problems:
+26 -1
View File
@@ -1,20 +1,39 @@
services: services:
# B-91 (SEC-51): one-shot, as root, so the bind-mounted ./data belongs to the non-root
# user the app runs as (uid 1000) and is closed to everyone else — also the first time
# after an older root-owned install, with no sudo on the host.
data-permissions:
build: .
image: plm-lottery-app:local
user: "0:0"
entrypoint: ["/bin/sh", "-c", "chown -R 1000:1000 /data && chmod -R go-rwx /data"]
volumes:
- ./data:/data
restart: "no"
app: app:
build: . build: .
image: plm-lottery-app:local
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db - DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc - MASTER_KEY_PATH=/app/key_data/master.xprv.enc
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
secrets:
- xprv_encryption_key
volumes: volumes:
- ./data/db:/app/db_data - ./data/db:/app/db_data
- ./data/keys:/app/key_data - ./data/keys:/app/key_data
- ./data/logs:/app/logs - ./data/logs:/app/logs
expose: expose:
- "8123" - "8123"
depends_on:
data-permissions:
condition: service_completed_successfully
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
@@ -28,6 +47,12 @@ services:
depends_on: depends_on:
- app - app
# The Fernet key that protects the master key lives in its own file, outside ./data (a backup of
# the data does not carry both) and in no environment variable (SEC-53).
secrets:
xprv_encryption_key:
file: ./secrets/xprv_encryption.key
volumes: volumes:
caddy_data: caddy_data:
caddy_config: caddy_config:
@@ -1,6 +1,6 @@
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server # ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
- **Stato:** Accettata — implementazione da completare (B-91) - **Stato:** Accettata
- **Area:** Custodia, chiavi e indirizzi - **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -23,5 +23,3 @@ git. L'utente non controlla nulla finché non preleva.
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` · *Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
*Test:* `tests/unit/test_hd.py` *Test:* `tests/unit/test_hd.py`
*Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la
variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`.
@@ -1,6 +1,6 @@
# ADR-11 — Il server rifiuta di partire con segreti inadeguati # ADR-11 — Il server rifiuta di partire con segreti inadeguati
- **Stato:** Accettata — implementazione da completare (B-91) - **Stato:** Accettata
- **Area:** Configurazione e ambiente - **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -24,8 +24,6 @@ chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` · *Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_config.py` *Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la La chiave Fernet arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file [sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
mancante o vuoto. mancante o vuoto.
+6 -10
View File
@@ -55,9 +55,8 @@ flowchart LR
installate da un file di lock con gli hash installate da un file di lock con gli hash
([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice ([sicurezza.md](sicurezza.md#sec-51) SEC-51, SEC-52, SEC-53 (B-91)). I controlli che richiedono il codice
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]). applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` *Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` · `requirements.lock` ·
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, *Test:* `tests/unit/test_container_hardening.py`
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
- **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta - **[ARC-04]** `app` è solo `expose`d sulla rete interna di compose (porta
8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e 8123), mai pubblicato sull'host: l'unico ingresso è Caddy, che pubblica 80 e
443. Su questo si basa la lettura dell'IP del client (vedi 443. Su questo si basa la lettura dell'IP del client (vedi
@@ -79,9 +78,9 @@ flowchart LR
rilascio, così un rollback sa quale immagine rimettere rilascio, così un rollback sa quale immagine rimettere
([operativita.md](operativita.md) (B-93)). ([operativita.md](operativita.md) (B-93)).
*Codice:* `docker-compose.yml` *Codice:* `docker-compose.yml`
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la *Test:* `tests/unit/test_container_hardening.py`
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default *Stato:* da implementare (B-93), per l'etichetta per rilascio · *Oggi:*
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta. l'immagine di `app` ha l'etichetta fissa `plm-lottery-app:local`.
- **[ARC-61]** **Salute del container.** Il container `app` ha un - **[ARC-61]** **Salute del container.** Il container `app` ha un
`healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s `healthcheck` che interroga `http://127.0.0.1:8123/health/ready` ogni 30s
@@ -415,8 +414,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
veri. veri.
*Codice:* `app/config.py:validate_runtime_secrets` · *Codice:* `app/config.py:validate_runtime_secrets` ·
*Test:* `tests/unit/test_config.py` *Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91) · *Oggi:* si controlla che la variabile
`XPRV_ENCRYPTION_KEY` non sia vuota.
- **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`, - **[ARC-20]** Gli endpoint Electrum vengono letti da `ELECTRUM_HOST`,
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS` `ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
(`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un (`host:porta[:notls][:pin=<sha256>]` separati da virgola, [ARC-30]); un
@@ -598,8 +595,7 @@ La configurazione è divisa in due posti con regole diverse.
| `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) | | `enable_api_docs` | `ENABLE_API_DOCS` | `False` | Espone Swagger/ReDoc/OpenAPI ([ARC-14]) |
*Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py` *Codice:* `app/config.py:Settings` · *Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE` · *Oggi:* la
chiave Fernet è la variabile `XPRV_ENCRYPTION_KEY`.
- **[ARC-31]** **Parametri operativi e di business** (costo della scommessa, - **[ARC-31]** **Parametri operativi e di business** (costo della scommessa,
durata e pausa del round, animazione, commissione di rete, timeout RBF, durata e pausa del round, animazione, commissione di rete, timeout RBF,
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
+1 -1
View File
@@ -581,7 +581,7 @@ restano nella tabella come storico.
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme `secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e `server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
`server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave `server_seed_enc`: il segreto **cifrato con Fernet** usando la stessa chiave
che cifra la master xprv (`XPRV_ENCRYPTION_KEY`, dopo B-91 il file di che cifra la master xprv (il file di cifratura,
[sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene [sicurezza.md](sicurezza.md#sec-53) SEC-53). Il segreto in chiaro non viene
mai scritto nel database prima della rivelazione: `server_seed` resta mai scritto nel database prima della rivelazione: `server_seed` resta
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa `NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
+5 -5
View File
@@ -44,9 +44,9 @@ flowchart LR
Docker con nome `caddy_data` e `caddy_config`, non in `./data`. Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
*Codice:* `docker-compose.yml`, `Caddyfile` *Codice:* `docker-compose.yml`, `Caddyfile`
Oggi il container gira come root, e i file che scrive in `./data` (database, Il container gira come utente non root (uid 1000, quello dell'utente dell'host:
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91 un servizio `data-permissions`, a ogni avvio, riporta `./data` a quel
gira come utente non root, con `./data` in `0700` e i file di chiave in proprietario), con `./data` in `0700` e i file di chiave in
`0600`, e la chiave di cifratura sta in un file a parte `0600`, e la chiave di cifratura sta in un file a parte
([architettura.md](architettura.md) ARC-03, ARC-05; ([architettura.md](architettura.md) ARC-03, ARC-05;
[SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data` [SEC-51](sicurezza.md#sec-51)); le procedure che scrivono in `./data`
@@ -106,7 +106,7 @@ Il file da salvare è `./data/keys/master.xprv.enc`, quello che il container
legge. Su questa installazione esiste anche una seconda copia, legge. Su questa installazione esiste anche una seconda copia,
`./master.xprv.enc` nella radice del progetto, a cui punta il `./master.xprv.enc` nella radice del progetto, a cui punta il
`MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è `MASTER_KEY_PATH` di `.env` (B-71): oggi decifrano alla stessa xprv, ma non è
il file dell'app. Dopo B-91 la chiave di cifratura non sta più in `.env` ma il file dell'app. La chiave di cifratura non sta più in `.env` ma
nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53), nel file `./secrets/xprv_encryption.key` ([SEC-53](sicurezza.md#sec-53),
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come [architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
oggi la variabile, separata dalla master key. oggi la variabile, separata dalla master key.
@@ -750,7 +750,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`. root con permessi `0644`: `sudo chmod 600 data/keys/master.xprv.enc`.
Gli script scrivono `0600` e rifiutano una xprv i cui Gli script scrivono `0600` e rifiutano una xprv i cui
indirizzi non coincidono con quelli del database indirizzi non coincidono con quelli del database
([SEC-50](sicurezza.md#sec-50)); dopo B-91 la chiave sta in un file e non ([SEC-50](sicurezza.md#sec-50)); la chiave sta in un file e non
in `.env` ([SEC-53](sicurezza.md#sec-53)). in `.env` ([SEC-53](sicurezza.md#sec-53)).
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la **Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
+16 -18
View File
@@ -1,13 +1,13 @@
# Setup # Setup
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi poterla avviare. Il server gira sempre in Docker; il venv serve solo a test, migrazioni da generare e script una tantum. Per come avviarla poi ogni volta, vedi
[running-the-server.md](running-the-server.md). [running-the-server.md](running-the-server.md).
## 1. Prerequisiti ## 1. Prerequisiti
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker) - Docker + Docker Compose (il server gira solo così)
- Docker + Docker Compose (serve solo per il workflow a container) - Python 3.12+ (solo per gli strumenti locali: test, `alembic revision` e gli script delle chiavi)
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo - Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio. produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
@@ -31,7 +31,7 @@ cp .env.example .env
| Variabile | Scopo | Come generarla | | Variabile | Scopo | Come generarla |
|---|---|---| |---|---|---|
| `XPRV_ENCRYPTION_KEY` | Chiave simmetrica che cifra a riposo la master xprv del server. **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` | | `XPRV_ENCRYPTION_KEY_FILE` | Percorso del file che contiene la chiave simmetrica che cifra a riposo la master xprv del server (`./secrets/xprv_encryption.key`, cartella `0700`, file `0600`; nel container è montato come secret). **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `mkdir -p secrets && chmod 700 secrets && python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" > secrets/xprv_encryption.key && chmod 600 secrets/xprv_encryption.key` |
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` | | `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` | | `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
@@ -61,7 +61,7 @@ certificato del primario. Attenzione: un valore scritto male **blocca
l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
disservizio in cui il fallback serve davvero. disservizio in cui il fallback serve davvero.
`JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti `JWT_SECRET` e la chiave in `XPRV_ENCRYPTION_KEY_FILE` vengono verificati all'avvio: se sono vuoti
(o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con (o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env` un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
contiene solo segreti e configurazione di infrastruttura — i parametri di contiene solo segreti e configurazione di infrastruttura — i parametri di
@@ -74,15 +74,13 @@ admin dopo l'avvio, non qui — vedi [guida-admin.md](guida-admin.md).
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
un'unica master xprv, generata una volta e cifrata a riposo con un'unica master xprv, generata una volta e cifrata a riposo con
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per la chiave in `XPRV_ENCRYPTION_KEY_FILE`. Questo passaggio va eseguito esattamente una volta per
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`: ogni deployment, dopo aver creato quel file e impostato `XPRV_ENCRYPTION_KEY_FILE` in `.env`:
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py` Dal venv: `PYTHONPATH=. python scripts/generate_master_key.py`, con `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, così lo script scrive esattamente il file che legge il container.
- **Docker**: `docker compose run --rm app python scripts/generate_master_key.py`
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in Questo scrive un file cifrato (`MASTER_KEY_PATH`, in Docker `./data/keys/master.xprv.enc`). **Fai il backup di questo
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo file insieme a `secrets/xprv_encryption.key`** — uno dei due da solo è inutile, ma
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
possibilità di recupero. possibilità di recupero.
@@ -107,10 +105,10 @@ Entrambi vanno eseguiti localmente (o dentro il container via
pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di pannello admin: chi ottiene questa xprv ottiene il controllo dei fondi di
tutti gli utenti e del pool. tutti gli utenti e del pool.
## 4. Installare le dipendenze (solo workflow locale/venv) ## 4. Installare gli strumenti locali (venv)
Salta questo passaggio se usi solo Docker — l'immagine installa le proprie Servono per i test e per gli script delle chiavi; il server non ne ha bisogno,
dipendenze durante la build. perché l'immagine installa le proprie dipendenze (da `requirements.lock`).
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
@@ -120,9 +118,9 @@ pip install -e ".[dev]"
## 5. Applicare le migrazioni del database ## 5. Applicare le migrazioni del database
- **Locale/venv**: `alembic upgrade head` Le migrazioni vengono eseguite automaticamente all'avvio del container (vedi il
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del `CMD` del `Dockerfile`) — nessun passaggio manuale, e `alembic upgrade head` non
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale. si lancia mai a mano.
## 6. Impostare l'indirizzo delle fee ## 6. Impostare l'indirizzo delle fee
+4 -26
View File
@@ -104,11 +104,7 @@ Restano **accettati**, e descritti in
| Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore | | Hash delle password | Tabella `users` (SQLite, `./data/db/`) | Argon2id | Attacco offline a dizionario, rallentato dai parametri Argon2 | — | Reset dall'operatore |
| JWT dell'utente | Browser, `localStorage` (`plm_token`) | Emesso al login/registrazione | Sessione dell'utente fino alla scadenza (24h) o al cambio password | — | Cambio password o reset dall'operatore | | JWT 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 `JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente (B-91): le
`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 vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
l'host. l'host.
@@ -118,10 +114,6 @@ l'host.
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da `master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
git sia dall'immagine Docker. git sia dall'immagine Docker.
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore` *Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
*Stato:* da implementare (B-91), per la chiave in un file e l'esclusione di
`secrets/` · *Oggi:* anche la chiave di cifratura sta in `.env`
(`XPRV_ENCRYPTION_KEY`), e né `.gitignore` né `.dockerignore` escludono
`secrets/`.
- **[SEC-03]** Tutti i parametri operativi stanno nel database - **[SEC-03]** Tutti i parametri operativi stanno nel database
(`round_config`); in `.env` restano solo segreti, collegamenti (`round_config`); in `.env` restano solo segreti, collegamenti
@@ -131,7 +123,6 @@ l'host.
riavvio. L'elenco completo è in riavvio. L'elenco completo è in
[architettura.md](architettura.md#configurazione). [architettura.md](architettura.md#configurazione).
*Codice:* `app/config.py:Settings` *Codice:* `app/config.py:Settings`
*Stato:* da implementare (B-91), per `XPRV_ENCRYPTION_KEY_FILE`.
`.env.example` documenta le chiavi necessarie e il comando per generarle; `.env.example` documenta le chiavi necessarie e il comando per generarle;
non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque non elenca `DATABASE_URL` né `MASTER_KEY_PATH`, che in Docker sono comunque
@@ -204,10 +195,7 @@ sì che un backup dei dati non contenga entrambe.
container, quindi `docker inspect` non la mostra, e non finisce in un container, quindi `docker inspect` non la mostra, e non finisce in un
backup di `./data/`. backup di `./data/`.
*Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`, *Codice:* `app/config.py:Settings`, `app/wallet/keystore.py:decrypt_xprv`,
`docker-compose.yml` · *Test:* — `docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`, `tests/unit/test_config.py`
*Stato:* da implementare (B-91) · *Oggi:* la chiave è la variabile
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
visibile con `docker inspect`.
### Derivazione ### Derivazione
@@ -265,8 +253,6 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
reali. reali.
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` · *Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
*Test:* `tests/unit/test_config.py` *Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91), per la chiave letta da file · *Oggi:* si
controlla che la variabile `XPRV_ENCRYPTION_KEY` non sia vuota.
- **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello - **[SEC-13]** Un `ADMIN_TOKEN` vuoto **non** blocca l'avvio: il pannello
risulta semplicemente chiuso (vedi [SEC-24]). risulta semplicemente chiuso (vedi [SEC-24]).
@@ -628,11 +614,7 @@ entra nell'immagine.
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno `0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
`0600`; tutti appartengono all'utente con cui gira il container. I file che `0600`; tutti appartengono all'utente con cui gira il container. I file che
l'app crea (database, backup, log) nascono con permessi `0600`. l'app crea (database, backup, log) nascono con permessi `0600`.
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* — *Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root, i
file che scrive in `./data/` appartengono a root sull'host, e `.env` e i
file di chiave hanno i permessi predefiniti del sistema (di norma `0644`,
leggibili da ogni utente dell'host).
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base - **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate (`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
@@ -640,11 +622,7 @@ entra nell'immagine.
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
un'immagine significa rigenerare il lock o cambiare il digest in un commit. un'immagine significa rigenerare il lock o cambiare il digest in un commit.
*Codice:* `Dockerfile`, `docker-compose.yml`, `pyproject.toml` · *Test:* — *Codice:* `Dockerfile`, `docker-compose.yml`, `requirements.lock` · *Test:* `tests/unit/test_container_hardening.py`
*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, è La chiave di cifratura montata come file, e non come variabile d'ambiente, è
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il [SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
+7 -8
View File
@@ -193,8 +193,8 @@ limiti accettati: lo stato `drawing` non riprende dopo un riavvio (vedi
| B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC | | B-68 | CLAUDE.md e README descrivevano uno stato che il codice aveva superato | Bassa | `162ceed` Describe the code the docs actually ship with | `test_docs_current.py` | ARC |
| B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC | | B-69 | Commenti nel codice non più veri | Bassa | `666cb1a` Retire in-code comments that outlived what they described | `test_code_comments.py` | ARC |
| B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR | | B-70 | Il diagramma diceva che prelievo e scommessa in corso non potevano coesistere, ma il codice non lo impone (e non deve) | Bassa | `526a649` Say what bets and withdrawals actually exclude | `test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`, `::test_the_docs_no_longer_claim_bets_and_withdrawals_are_exclusive` | WDR |
| B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | **aperto** | — | OPS, SEC | | B-71 | `.env` punta `MASTER_KEY_PATH` a una seconda copia della chiave master (e `DATABASE_URL` a un secondo database) invece che ai file in `./data/` letti dal container | Bassa | corretto con l'allineamento dei file e B-89 | `test_master_key_check.py` | OPS, SEC |
| B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS | | B-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | *da registrare* | — (documentazione) | OPS |
Note: Note:
@@ -239,7 +239,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC | | B-88 | Le transazioni SQLite non sono quelle che il codice presuppone; `synchronous=NORMAL` può perdere gli ultimi commit | Alta | *da registrare* | `test_sqlite_transactions.py`, `test_db_base.py` | DAT, ARC |
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS | | B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG | | B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | **aperto** | — | SEC, OPS | | B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS |
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API | | B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS | | B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API | | B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
@@ -252,11 +252,10 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
## Stato attuale ## Stato attuale
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il - **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-95, B-98, B-99, B-100. come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-95, B-98, B-99, B-100.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e - **Aperti:** dalla revisione del 2026-09-28, B-92, B-93, B-94 e B-96 (0 critici,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici, 0 alti, 4 medi), da implementare nell'ordine di
3 alti, 5 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md). [piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-101`. - **Prossimo numero libero:** `B-101`.
+1301
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -8,7 +8,7 @@ locally, only when you actually need it (e.g. disaster-recovery backup).
Usage: Usage:
python scripts/decrypt_master_key.py python scripts/decrypt_master_key.py
Requires XPRV_ENCRYPTION_KEY to already be set (.env), same as the running Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file, same as the running
server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path server. Looks for the encrypted key at MASTER_KEY_PATH (.env) — if that path
doesn't exist, falls back to ./data/keys/master.xprv.enc, the location doesn't exist, falls back to ./data/keys/master.xprv.enc, the location
docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc) docker-compose.yml bind-mounts it to, since .env's default (./master.xprv.enc)
@@ -34,7 +34,7 @@ def _resolve_key_path() -> str:
if __name__ == "__main__": if __name__ == "__main__":
if not settings.xprv_encryption_key: if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set") raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
key_path = _resolve_key_path() key_path = _resolve_key_path()
+3 -3
View File
@@ -1,6 +1,6 @@
"""One-time ops bootstrap: encrypt an externally-generated master xprv and """One-time ops bootstrap: encrypt an externally-generated master xprv and
write it to disk, using the exact same scheme generate_master_key.py uses write it to disk, using the exact same scheme generate_master_key.py uses
(Fernet, XPRV_ENCRYPTION_KEY). Use this instead of generate_master_key.py (Fernet, the key in XPRV_ENCRYPTION_KEY_FILE). Use this instead of generate_master_key.py
when you already have an xprv from elsewhere (e.g. generated offline/air- when you already have an xprv from elsewhere (e.g. generated offline/air-
gapped) and want to bring your own instead of letting the server create one. gapped) and want to bring your own instead of letting the server create one.
@@ -12,7 +12,7 @@ Usage:
python scripts/encrypt_master_key.py python scripts/encrypt_master_key.py
python scripts/encrypt_master_key.py --overwrite # replace an existing key python scripts/encrypt_master_key.py --overwrite # replace an existing key
Requires XPRV_ENCRYPTION_KEY to already be set (.env). Writes to Requires XPRV_ENCRYPTION_KEY_FILE (.env) to point at the key file. Writes to
MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the MASTER_KEY_PATH (.env) — falls back to ./data/keys/master.xprv.enc (the
docker-compose.yml bind-mount location) if that directory exists and .env's docker-compose.yml bind-mount location) if that directory exists and .env's
default path doesn't, same fallback as decrypt_master_key.py. default path doesn't, same fallback as decrypt_master_key.py.
@@ -48,7 +48,7 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
if not settings.xprv_encryption_key: if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set") raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
key_path = _resolve_key_path() key_path = _resolve_key_path()
if os.path.exists(key_path) and not args.overwrite: if os.path.exists(key_path) and not args.overwrite:
+2 -2
View File
@@ -2,7 +2,7 @@
Usage: Usage:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# put that value in XPRV_ENCRYPTION_KEY (.env), then: # write it to the file XPRV_ENCRYPTION_KEY_FILE (.env) points at, then:
python scripts/generate_master_key.py python scripts/generate_master_key.py
""" """
@@ -12,7 +12,7 @@ from app.wallet.verify import load_sample
if __name__ == "__main__": if __name__ == "__main__":
if not settings.xprv_encryption_key: if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set") raise SystemExit("XPRV_ENCRYPTION_KEY_FILE is not set, or its file is missing or empty")
sample = load_sample() sample = load_sample()
if sample.users or sample.rounds or sample.seeds: if sample.users or sample.rounds or sample.seeds:
# SEC-50: a new key cannot reproduce the addresses the database already holds. # SEC-50: a new key cannot reproduce the addresses the database already holds.
+70
View File
@@ -0,0 +1,70 @@
"""B-91 (SEC-51, SEC-52, SEC-53): non-root, pinned images and dependencies, the key in a file."""
import re
from pathlib import Path
import pytest
from app.config import ConfigError, Settings, validate_runtime_secrets
ROOT = Path(__file__).resolve().parents[2]
def _read(name):
return (ROOT / name).read_text()
def test_the_dockerfile_pins_its_base_image_and_runs_as_a_non_root_user():
dockerfile = _read("Dockerfile")
assert re.search(r"^FROM python:3\.12-slim@sha256:[0-9a-f]{64}$", dockerfile, re.M)
assert re.search(r"^USER 1000:1000$", dockerfile, re.M)
assert "--require-hashes" in dockerfile and "umask 077" in dockerfile
def test_every_locked_requirement_carries_a_hash():
lock = _read("requirements.lock")
pinned = re.findall(r"^([A-Za-z0-9_.\-]+==\S+) \\$", lock, re.M)
assert len(pinned) > 20
blocks = re.split(r"\n(?=[A-Za-z0-9_.\-]+==)", lock)[1:]
assert all("--hash=sha256:" in block for block in blocks)
def test_compose_pins_caddy_and_keeps_the_key_out_of_the_environment():
compose = _read("docker-compose.yml")
assert re.search(r"image: caddy:2-alpine@sha256:[0-9a-f]{64}", compose)
assert "XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key" in compose
assert "file: ./secrets/xprv_encryption.key" in compose
assert not re.search(r"XPRV_ENCRYPTION_KEY=", compose)
assert "data-permissions" in compose and "service_completed_successfully" in compose
def test_the_example_env_points_at_a_file_not_a_key():
example = _read(".env.example")
assert re.search(r"^XPRV_ENCRYPTION_KEY_FILE=", example, re.M)
assert not re.search(r"^XPRV_ENCRYPTION_KEY=", example, re.M)
@pytest.mark.parametrize("ignore", [".gitignore", ".dockerignore"])
def test_the_secrets_folder_is_kept_out_of_git_and_the_image(ignore):
assert "secrets/" in _read(ignore).split()
def _settings(**overrides):
return Settings(_env_file=None, jwt_secret="x" * 32, **overrides)
def test_the_key_is_read_from_its_file(tmp_path):
key_file = tmp_path / "k"
key_file.write_text("a-fernet-key\n")
config = _settings(xprv_encryption_key_file=str(key_file))
assert config.xprv_encryption_key == "a-fernet-key"
validate_runtime_secrets(config)
@pytest.mark.parametrize("content", [None, " \n"])
def test_a_missing_or_empty_key_file_stops_the_server_naming_the_file(tmp_path, content):
key_file = tmp_path / "k"
if content is not None:
key_file.write_text(content)
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY_FILE .*is missing or empty"):
validate_runtime_secrets(_settings(xprv_encryption_key_file=str(key_file)))