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:
1 parent
40ac490826
commit
c1b192f6d1
23 files changed
+1500
-183
No files matched your search
@@ -11,3 +11,4 @@ data/
|
||||
*.egg-info/
|
||||
tests/
|
||||
.claude/
|
||||
secrets/
|
||||
+8
-3
@@ -30,9 +30,14 @@ ELECTRUM_CERT_PIN=
|
||||
# NEVER true in production.
|
||||
ALLOW_NO_QUORUM=false
|
||||
|
||||
# Fernet key protecting the master xprv at rest. Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
XPRV_ENCRYPTION_KEY=
|
||||
# File holding the Fernet key that protects the master xprv at rest (B-91): in a file, not in
|
||||
# an environment variable, so `docker inspect` cannot show it and a backup of ./data does not
|
||||
# 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:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
|
||||
@@ -8,3 +8,4 @@ master.xprv.enc
|
||||
*.egg-info/
|
||||
logs/
|
||||
data/
|
||||
secrets/
|
||||
@@ -1,77 +1,10 @@
|
||||
# BUGS.md — open findings
|
||||
|
||||
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
|
||||
2026-09-28 specification review (B-73 … B-98, at the end of the file).
|
||||
|
||||
## Audit of 2026-08-03
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
|
||||
(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
|
||||
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.
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
### B-92 — No health, readiness or real audit browsing
|
||||
|
||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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/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
|
||||
|
||||
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::test_derivation_is_deterministic # one test
|
||||
```
|
||||
|
||||
`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)
|
||||
|
||||
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
|
||||
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.
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
## PLM network parameters (mainnet)
|
||||
|
||||
+13
-3
@@ -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
|
||||
|
||||
COPY requirements.lock ./
|
||||
RUN pip install --no-cache-dir --require-hashes --no-deps -r requirements.lock
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
COPY migrations ./migrations
|
||||
COPY alembic.ini ./
|
||||
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
|
||||
|
||||
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"]
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
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,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
+18
-1
@@ -19,6 +19,14 @@ logger = logging.getLogger(__name__)
|
||||
class Settings(BaseSettings):
|
||||
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"
|
||||
|
||||
electrum_host: str = "santantonio.sytes.net"
|
||||
@@ -45,6 +53,10 @@ class Settings(BaseSettings):
|
||||
start_paused: bool = False
|
||||
|
||||
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"
|
||||
jwt_secret: str = ""
|
||||
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))")'
|
||||
)
|
||||
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(
|
||||
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
|
||||
where + " (generate: python -c "
|
||||
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
||||
)
|
||||
if problems:
|
||||
|
||||
@@ -115,7 +115,7 @@ def verify_master_key() -> None:
|
||||
except Exception as exc:
|
||||
raise ConfigError(
|
||||
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
|
||||
problems = sample_problems(account, load_sample(), settings.xprv_encryption_key)
|
||||
if problems:
|
||||
|
||||
+26
-1
@@ -1,20 +1,39 @@
|
||||
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:
|
||||
build: .
|
||||
image: plm-lottery-app:local
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
|
||||
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
|
||||
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
|
||||
secrets:
|
||||
- xprv_encryption_key
|
||||
volumes:
|
||||
- ./data/db:/app/db_data
|
||||
- ./data/keys:/app/key_data
|
||||
- ./data/logs:/app/logs
|
||||
expose:
|
||||
- "8123"
|
||||
depends_on:
|
||||
data-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
image: caddy:2-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -28,6 +47,12 @@ services:
|
||||
depends_on:
|
||||
- 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:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
- **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` ·
|
||||
*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
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
||||
- **Stato:** Accettata
|
||||
- **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*.
|
||||
|
||||
@@ -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` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
|
||||
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
|
||||
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||
La chiave Fernet arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
|
||||
mancante o vuoto.
|
||||
+6
-10
@@ -55,9 +55,8 @@ flowchart LR
|
||||
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
|
||||
applicativo (chiave, quorum, ripristino) sono nel lifespan ([ARC-60]).
|
||||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* il container gira come root,
|
||||
`python:3.12-slim` non è fissato per digest e le dipendenze sono `>=`.
|
||||
*Codice:* `Dockerfile` (`CMD`) · `migrations/env.py` · `requirements.lock` ·
|
||||
*Test:* `tests/unit/test_container_hardening.py`
|
||||
- **[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
|
||||
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
|
||||
([operativita.md](operativita.md) (B-93)).
|
||||
*Codice:* `docker-compose.yml`
|
||||
*Stato:* da implementare (B-91, B-93) · *Oggi:* la chiave di cifratura è la
|
||||
variabile `XPRV_ENCRYPTION_KEY` in `.env`, i permessi sono quelli di default
|
||||
(`.env` e file di chiave leggibili da tutti) e l'immagine non ha etichetta.
|
||||
*Test:* `tests/unit/test_container_hardening.py`
|
||||
*Stato:* da implementare (B-93), per l'etichetta per rilascio · *Oggi:*
|
||||
l'immagine di `app` ha l'etichetta fissa `plm-lottery-app:local`.
|
||||
|
||||
- **[ARC-61]** **Salute del container.** Il container `app` ha un
|
||||
`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.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` ·
|
||||
*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`,
|
||||
`ELECTRUM_PORT`, `ELECTRUM_USE_SSL` ed `ELECTRUM_FALLBACK_SERVERS`
|
||||
(`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]) |
|
||||
|
||||
*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,
|
||||
durata e pausa del round, animazione, commissione di rete, timeout RBF,
|
||||
indirizzo commissioni, pausa di manutenzione e suo motivo, conferme dei
|
||||
|
||||
@@ -581,7 +581,7 @@ restano nella tabella come storico.
|
||||
`secrets.token_bytes(32)`. All'inserimento del round si scrivono insieme
|
||||
`server_seed_hash` (`sha256` di quei 32 byte, esadecimale minuscolo) e
|
||||
`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
|
||||
mai scritto nel database prima della rivelazione: `server_seed` resta
|
||||
`NULL` e si valorizza (esadecimale minuscolo, 64 caratteri) nella stessa
|
||||
|
||||
+5
-5
@@ -44,9 +44,9 @@ flowchart LR
|
||||
Docker con nome `caddy_data` e `caddy_config`, non in `./data`.
|
||||
*Codice:* `docker-compose.yml`, `Caddyfile`
|
||||
|
||||
Oggi il container gira come root, e i file che scrive in `./data` (database,
|
||||
backup, chiave cifrata dagli script) appartengono a root sull'host. Dopo B-91
|
||||
gira come utente non root, con `./data` in `0700` e i file di chiave in
|
||||
Il container gira come utente non root (uid 1000, quello dell'utente dell'host:
|
||||
un servizio `data-permissions`, a ogni avvio, riporta `./data` a quel
|
||||
proprietario), con `./data` in `0700` e i file di chiave in
|
||||
`0600`, e la chiave di cifratura sta in un file a parte
|
||||
([architettura.md](architettura.md) ARC-03, ARC-05;
|
||||
[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,
|
||||
`./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 è
|
||||
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),
|
||||
[architettura.md](architettura.md) ARC-05), fuori da `./data/`: si salva come
|
||||
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`.
|
||||
Gli script scrivono `0600` e rifiutano una xprv i cui
|
||||
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)).
|
||||
|
||||
**Se è trapelata la master key stessa** (file cifrato *e* chiave, oppure la
|
||||
|
||||
+16
-18
@@ -1,13 +1,13 @@
|
||||
# Setup
|
||||
|
||||
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).
|
||||
|
||||
## 1. Prerequisiti
|
||||
|
||||
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker)
|
||||
- Docker + Docker Compose (serve solo per il workflow a container)
|
||||
- Docker + Docker Compose (il server gira solo così)
|
||||
- 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
|
||||
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.
|
||||
@@ -31,7 +31,7 @@ cp .env.example .env
|
||||
|
||||
| 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))"` |
|
||||
| `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
|
||||
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
|
||||
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
|
||||
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
|
||||
un'unica master xprv, generata una volta e cifrata a riposo con
|
||||
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per
|
||||
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`:
|
||||
la chiave in `XPRV_ENCRYPTION_KEY_FILE`. Questo passaggio va eseguito esattamente una volta per
|
||||
ogni deployment, dopo aver creato quel file e impostato `XPRV_ENCRYPTION_KEY_FILE` in `.env`:
|
||||
|
||||
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py`
|
||||
- **Docker**: `docker compose run --rm app 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.
|
||||
|
||||
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in
|
||||
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo
|
||||
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
|
||||
Questo scrive un file cifrato (`MASTER_KEY_PATH`, in Docker `./data/keys/master.xprv.enc`). **Fai il backup di questo
|
||||
file insieme a `secrets/xprv_encryption.key`** — uno dei due da solo è inutile, ma
|
||||
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
|
||||
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
|
||||
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
|
||||
dipendenze durante la build.
|
||||
Servono per i test e per gli script delle chiavi; il server non ne ha bisogno,
|
||||
perché l'immagine installa le proprie dipendenze (da `requirements.lock`).
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
@@ -120,9 +118,9 @@ pip install -e ".[dev]"
|
||||
|
||||
## 5. Applicare le migrazioni del database
|
||||
|
||||
- **Locale/venv**: `alembic upgrade head`
|
||||
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del
|
||||
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale.
|
||||
Le migrazioni vengono eseguite automaticamente all'avvio del container (vedi il
|
||||
`CMD` del `Dockerfile`) — nessun passaggio manuale, e `alembic upgrade head` non
|
||||
si lancia mai a mano.
|
||||
|
||||
## 6. Impostare l'indirizzo delle fee
|
||||
|
||||
|
||||
+4
-26
@@ -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 |
|
||||
| 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
|
||||
`JWT_SECRET` e `ADMIN_TOKEN` restano variabili d'ambiente (B-91): le
|
||||
vede con `docker inspect` solo chi ha accesso a Docker, che è già fidato come
|
||||
l'host.
|
||||
|
||||
@@ -118,10 +114,6 @@ l'host.
|
||||
`master.xprv.enc`, i database `*.db`, `data/` e `logs/` sono esclusi sia da
|
||||
git sia dall'immagine Docker.
|
||||
*Codice:* `app/config.py:Settings`, `.gitignore`, `.dockerignore`
|
||||
*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
|
||||
(`round_config`); in `.env` restano solo segreti, collegamenti
|
||||
@@ -131,7 +123,6 @@ l'host.
|
||||
riavvio. L'elenco completo è in
|
||||
[architettura.md](architettura.md#configurazione).
|
||||
*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;
|
||||
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
|
||||
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 variabile
|
||||
`XPRV_ENCRYPTION_KEY` di `.env`, passata al container con `env_file` e
|
||||
visibile con `docker inspect`.
|
||||
`docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`, `tests/unit/test_config.py`
|
||||
|
||||
### Derivazione
|
||||
|
||||
@@ -265,8 +253,6 @@ riserva è [ELC-19](connessione-electrum.md#1-rotazione-tra-server) (vedi
|
||||
reali.
|
||||
*Codice:* `app/config.py:validate_runtime_secrets`, `app/main.py:lifespan` ·
|
||||
*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
|
||||
risulta semplicemente chiuso (vedi [SEC-24]).
|
||||
@@ -628,11 +614,7 @@ entra nell'immagine.
|
||||
`0700`; `.env`, `master.xprv.enc` e `./secrets/xprv_encryption.key` hanno
|
||||
`0600`; tutti appartengono all'utente con cui gira il container. I file che
|
||||
l'app crea (database, backup, log) nascono con permessi `0600`.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* —
|
||||
*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).
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml` · *Test:* `tests/unit/test_container_hardening.py`
|
||||
|
||||
- **[SEC-52]** Ciò che entra nelle immagini è fissato: le immagini di base
|
||||
(`python:3.12-slim` per `app`, `caddy:2-alpine` per `caddy`) sono indicate
|
||||
@@ -640,11 +622,7 @@ entra nell'immagine.
|
||||
di lock con l'hash di ogni pacchetto (`pip install --require-hashes`), non
|
||||
dagli intervalli `>=` di `pyproject.toml`. Aggiornare una dipendenza o
|
||||
un'immagine significa rigenerare il lock o cambiare il digest in un commit.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `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.
|
||||
*Codice:* `Dockerfile`, `docker-compose.yml`, `requirements.lock` · *Test:* `tests/unit/test_container_hardening.py`
|
||||
|
||||
La chiave di cifratura montata come file, e non come variabile d'ambiente, è
|
||||
[SEC-53]. Le procedure che scrivono in `./data/` devono rispettarne il
|
||||
|
||||
+7
-8
@@ -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-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-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-72 | `docs/setup.md` presenta ancora l'installazione come «in locale o via Docker», contro la regola «solo Docker» | Bassa | **aperto** | — | OPS |
|
||||
| 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 | *da registrare* | — (documentazione) | OPS |
|
||||
|
||||
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-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-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-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 |
|
||||
@@ -252,11 +252,10 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
|
||||
## 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
|
||||
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.
|
||||
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
|
||||
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici,
|
||||
3 alti, 5 medi), da implementare nell'ordine di
|
||||
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:** dalla revisione del 2026-09-28, B-92, B-93, B-94 e B-96 (0 critici,
|
||||
0 alti, 4 medi), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-101`.
|
||||
+1301
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,7 @@ locally, only when you actually need it (e.g. disaster-recovery backup).
|
||||
Usage:
|
||||
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
|
||||
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)
|
||||
@@ -34,7 +34,7 @@ def _resolve_key_path() -> str:
|
||||
|
||||
if __name__ == "__main__":
|
||||
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()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""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
|
||||
(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-
|
||||
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 --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
|
||||
docker-compose.yml bind-mount location) if that directory exists and .env's
|
||||
default path doesn't, same fallback as decrypt_master_key.py.
|
||||
@@ -48,7 +48,7 @@ if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
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()
|
||||
if os.path.exists(key_path) and not args.overwrite:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Usage:
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.wallet.verify import load_sample
|
||||
|
||||
if __name__ == "__main__":
|
||||
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()
|
||||
if sample.users or sample.rounds or sample.seeds:
|
||||
# SEC-50: a new key cannot reproduce the addresses the database already holds.
|
||||
|
||||
@@ -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)))
|
||||
Reference in new issue
Block a user